From 08a6edd4f297e8fabd17695eea342cddabad9ba0 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Mon, 12 Oct 2026 09:35:18 +0800 Subject: [PATCH 01/26] =?UTF-8?q?=E2=9C=A8=20Feature:=20Introduce=20jiuwen?= =?UTF-8?q?=20as=20an=20optional=20agent=20core.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/agents/create_agent_info.py | 1 + backend/consts/model.py | 2 + backend/database/agent_db.py | 2 + backend/database/db_models.py | 5 + .../management/services/agent/management.py | 2 + backend/management/services/agent/service.py | 1 + backend/pyproject.toml | 2 +- .../v2.7.2_1010_agent_core_factory.sql | 8 + sdk/nexent/core/agents/agent_model.py | 4 + .../core/agents/core_adapters/__init__.py | 7 + sdk/nexent/core/agents/core_adapters/base.py | 36 ++ .../core/agents/core_adapters/events.py | 41 ++ .../core/agents/core_adapters/factory.py | 43 ++ .../core/agents/core_adapters/jiuwen.py | 446 ++++++++++++++++++ sdk/nexent/core/agents/core_adapters/mcp.py | 160 +++++++ .../core/agents/core_adapters/skills.py | 33 ++ .../core/agents/core_adapters/smolagents.py | 31 ++ sdk/nexent/core/agents/run_agent.py | 160 ++++--- sdk/nexent/core/concurrency/cancellation.py | 45 +- sdk/pyproject.toml | 3 + test/backend/agents/test_create_agent_info.py | 3 + test/backend/database/test_agent_db.py | 1 + 22 files changed, 961 insertions(+), 75 deletions(-) create mode 100644 deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql create mode 100644 sdk/nexent/core/agents/core_adapters/__init__.py create mode 100644 sdk/nexent/core/agents/core_adapters/base.py create mode 100644 sdk/nexent/core/agents/core_adapters/events.py create mode 100644 sdk/nexent/core/agents/core_adapters/factory.py create mode 100644 sdk/nexent/core/agents/core_adapters/jiuwen.py create mode 100644 sdk/nexent/core/agents/core_adapters/mcp.py create mode 100644 sdk/nexent/core/agents/core_adapters/skills.py create mode 100644 sdk/nexent/core/agents/core_adapters/smolagents.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index d0d67e1c40..89159aa490 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -1906,6 +1906,7 @@ async def create_agent_config( agent_config = AgentConfig( + agent_core=agent_info.get("agent_core") or "smolagents", name="undefined" if agent_info["name"] is None else agent_info["name"], display_name=agent_info.get("display_name"), description="undefined" if agent_info["description"] is None else agent_info["description"], diff --git a/backend/consts/model.py b/backend/consts/model.py index 3e5d45ebac..48d7b1c70e 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1313,6 +1313,7 @@ class RelatedAgentInfo(BaseModel): class AgentInfoRequest(BaseModel): + agent_core: Optional[Literal["smolagents", "jiuwen"]] = None agent_id: Optional[int] = None name: Optional[str] = None display_name: Optional[str] = None @@ -1433,6 +1434,7 @@ class MessageIdRequest(BaseModel): class ExportAndImportAgentInfo(BaseModel): + agent_core: Literal["smolagents", "jiuwen"] = "smolagents" agent_id: int tenant_id: Optional[str] = None name: str diff --git a/backend/database/agent_db.py b/backend/database/agent_db.py index 55d32ce170..3225520cde 100644 --- a/backend/database/agent_db.py +++ b/backend/database/agent_db.py @@ -266,6 +266,7 @@ def create_agent(agent_info, tenant_id: str, user_id: str): :return: Created agent object """ info_with_metadata = dict(agent_info) + info_with_metadata.setdefault("agent_core", "smolagents") info_with_metadata.setdefault("max_steps", 15) info_with_metadata.setdefault("is_main_agent", True) info_with_metadata.setdefault("verification_config", None) @@ -293,6 +294,7 @@ def create_agent(agent_info, tenant_id: str, user_id: str): # Directly extract agent_id and return as dict result = { "agent_id": new_agent.agent_id, + "agent_core": new_agent.agent_core, "tenant_id": new_agent.tenant_id, "name": new_agent.name, "system_key": getattr(new_agent, "system_key", None), diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 314e31d4ca..b7d4eae7d4 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -669,6 +669,11 @@ class AgentInfo(TableBase): __tablename__ = "ag_tenant_agent_t" __table_args__ = {"schema": SCHEMA} + agent_core = Column( + String(30), nullable=False, default="smolagents", server_default=text("'smolagents'"), + comment="Execution core for this immutable agent version: smolagents or jiuwen", + ) + agent_id = Column(Integer, Sequence( "ag_tenant_agent_t_agent_id_seq", schema=SCHEMA), nullable=False, primary_key=True, autoincrement=True, doc="ID") version_no = Column(Integer, default=0, nullable=False, primary_key=True, diff --git a/backend/management/services/agent/management.py b/backend/management/services/agent/management.py index 8301042879..36116e7ad6 100644 --- a/backend/management/services/agent/management.py +++ b/backend/management/services/agent/management.py @@ -522,6 +522,7 @@ async def export_agent_by_agent_id( "display_name") if business_logic_model_info is not None else None agent_info = ExportAndImportAgentInfo(agent_id=agent_id, + agent_core=agent_info.get("agent_core") or "smolagents", tenant_id=agent_info["tenant_id"], name=agent_info["name"], display_name=agent_info["display_name"], @@ -701,6 +702,7 @@ async def import_agent_by_agent_id( "is_main_agent": getattr(import_agent_info, "is_main_agent", True), "provide_run_summary": import_agent_info.provide_run_summary, "allow_chat_metadata": import_agent_info.allow_chat_metadata, + "agent_core": import_agent_info.agent_core, "enable_protocol_repair_retry": getattr(import_agent_info, "enable_protocol_repair_retry", False), "verification_config": getattr(import_agent_info, "verification_config", None), "context_policy": getattr(import_agent_info, "context_policy", None), diff --git a/backend/management/services/agent/service.py b/backend/management/services/agent/service.py index 19c7ff2bee..a47ef7c874 100644 --- a/backend/management/services/agent/service.py +++ b/backend/management/services/agent/service.py @@ -718,6 +718,7 @@ async def update_agent_info_impl( user_group_ids = _get_user_group_ids(user_id, tenant_id) created = create_agent( agent_info={ + "agent_core": request.agent_core or "smolagents", "name": request.name, "display_name": request.display_name, "description": request.description, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7c7eb99573..7e9444f09a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "scikit-learn>=1.0.0", "numpy>=1.24.0", "defusedxml>=0.7.1", - "openjiuwen>=0.1.0", + "openjiuwen==0.1.18", "pydantic-settings>=2.0.0", "python-docx>=1.1.0", "xlrd>=2.0.1", diff --git a/deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql b/deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql new file mode 100644 index 0000000000..52d08daa0c --- /dev/null +++ b/deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql @@ -0,0 +1,8 @@ +-- Migration kind: REQUIRED_SCHEMA +-- Required for: Optional native Agent Core selection on every Agent version. +-- Preserve the execution core on each draft and published Agent version. +ALTER TABLE nexent.ag_tenant_agent_t + ADD COLUMN IF NOT EXISTS agent_core VARCHAR(30) NOT NULL DEFAULT 'smolagents'; + +COMMENT ON COLUMN nexent.ag_tenant_agent_t.agent_core IS + 'Execution core for this immutable agent version: smolagents or jiuwen'; diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index 21dc1a693f..400a2d7781 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -262,6 +262,10 @@ class AgentVerificationConfig(BaseModel): class AgentConfig(BaseModel): + agent_core: str = Field( + default="smolagents", pattern=r"^[a-z][a-z0-9_]{0,29}$", + description="Execution core selected by the AgentCoreFactory", + ) runtime_ref: Optional[str] = None origin: Literal["SYSTEM", "PERSISTED", "BUILTIN_RUNTIME"] = "PERSISTED" agent_id: Optional[Union[int, str]] = Field( diff --git a/sdk/nexent/core/agents/core_adapters/__init__.py b/sdk/nexent/core/agents/core_adapters/__init__.py new file mode 100644 index 0000000000..bf02e6b69c --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/__init__.py @@ -0,0 +1,7 @@ +"""Pluggable execution cores for Nexent agent configurations.""" + +from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig +from .factory import AgentCoreFactory + + +__all__ = ["AgentCoreAdapter", "AgentCoreFactory", "UnsupportedAgentCoreConfig"] diff --git a/sdk/nexent/core/agents/core_adapters/base.py b/sdk/nexent/core/agents/core_adapters/base.py new file mode 100644 index 0000000000..21bb338817 --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/base.py @@ -0,0 +1,36 @@ +"""Core-independent execution and lifecycle contract.""" + +from abc import ABC, abstractmethod +from typing import Any + + +class UnsupportedAgentCoreConfig(ValueError): + """A selected core cannot honor a requested execution policy.""" + + +class AgentCoreAdapter(ABC): + """Own one run; receive resolved resources without reading environment variables.""" + + core_name: str + + def __init__(self, run_info: Any): + self.run_info = run_info + + @classmethod + @abstractmethod + def capabilities(cls) -> frozenset[str]: + """Return supported execution capabilities.""" + + @abstractmethod + def validate(self) -> None: + """Reject unsupported settings before acquiring execution resources.""" + + def prepare(self) -> None: + """Bind resolved resources for this run.""" + + @abstractmethod + def run(self) -> None: + """Execute and emit the existing Nexent observer protocol.""" + + def close(self) -> None: + """Release only resources owned by this adapter; must be idempotent.""" diff --git a/sdk/nexent/core/agents/core_adapters/events.py b/sdk/nexent/core/agents/core_adapters/events.py new file mode 100644 index 0000000000..fb7d794c7d --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/events.py @@ -0,0 +1,41 @@ +"""Convert native Jiuwen stream output into Nexent observer messages.""" + +import json + +from ...utils.observer import ProcessType + + +class JiuwenEventAdapter: + def __init__(self, observer, agent_name): + self.observer = observer + self.agent_name = agent_name + self.final_answer = None + + def emit(self, chunk): + kind = getattr(chunk, "type", None) + payload = getattr(chunk, "payload", None) + if isinstance(chunk, dict): + kind, payload = chunk.get("type", kind), chunk.get("payload", payload) + if kind == "answer": + if self.final_answer is not None: + raise RuntimeError("Jiuwen emitted multiple terminal answers") + if not isinstance(payload, dict) or payload.get("result_type") == "error": + raise RuntimeError("Jiuwen agent execution failed") + output = payload.get("output") + if not isinstance(output, str) or not output.strip(): + raise RuntimeError("Jiuwen returned no final answer") + self.final_answer = output + elif kind == "llm_output" and isinstance(payload, dict): + content = payload.get("content") + if isinstance(content, str) and content: + self.observer.add_message(self.agent_name, ProcessType.MODEL_OUTPUT_THINKING, content) + reasoning = payload.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + self.observer.add_message(self.agent_name, ProcessType.MODEL_OUTPUT_DEEP_THINKING, reasoning) + elif kind == "context.usage" and isinstance(payload, dict): + self.observer.add_message(self.agent_name, ProcessType.TOKEN_COUNT, json.dumps(payload)) + + def finish(self): + if self.final_answer is None: + raise RuntimeError("Jiuwen stream ended without a terminal answer") + self.observer.add_message(self.agent_name, ProcessType.FINAL_ANSWER, self.final_answer) diff --git a/sdk/nexent/core/agents/core_adapters/factory.py b/sdk/nexent/core/agents/core_adapters/factory.py new file mode 100644 index 0000000000..feff1f12bb --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/factory.py @@ -0,0 +1,43 @@ +"""Registry and lazy construction of execution-core adapters.""" + +import re +from threading import RLock + +from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig + + +class AgentCoreFactory: + _adapters: dict[str, type[AgentCoreAdapter]] = {} + _lock = RLock() + + @classmethod + def register(cls, name: str, adapter: type[AgentCoreAdapter]) -> None: + """Register an extension without replacing built-in or existing adapters.""" + if not re.fullmatch(r"[a-z][a-z0-9_]{0,29}", name): + raise ValueError("Invalid agent core name") + if not isinstance(adapter, type) or not issubclass(adapter, AgentCoreAdapter): + raise TypeError("Adapter must inherit AgentCoreAdapter") + if adapter.core_name != name: + raise ValueError("Adapter core_name must match its registry name") + with cls._lock: + if name in {"smolagents", "jiuwen"} or name in cls._adapters: + raise ValueError(f"Agent core already registered: {name}") + cls._adapters[name] = adapter + + @classmethod + def create(cls, run_info, *, smolagents_executor=None) -> AgentCoreAdapter: + name = getattr(run_info.agent_config, "agent_core", "smolagents") + if name == "smolagents": + from .smolagents import SmolagentsAdapter + + return SmolagentsAdapter(run_info, executor=smolagents_executor) + elif name == "jiuwen": + from .jiuwen import JiuwenAdapter + + adapter = JiuwenAdapter + else: + with cls._lock: + adapter = cls._adapters.get(name) + if adapter is None: + raise UnsupportedAgentCoreConfig(f"Unknown agent core: {name}") + return adapter(run_info) diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/core_adapters/jiuwen.py new file mode 100644 index 0000000000..47c4b7451f --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/jiuwen.py @@ -0,0 +1,446 @@ +"""Compile Nexent resources into a native Jiuwen agent and run its Runner.""" + +import asyncio +import inspect +import json +import logging +import uuid +from contextlib import aclosing +from types import SimpleNamespace + +from ...concurrency import ManagedTaskSpec, RunCancellationScope +from ...concurrency.cancellation import RunTerminated +from ...tools.prompt_registry import PromptToolPolicySnapshot, filter_effective_prompt_tools +from ...utils.observer import ProcessType +from ..context.models import ContextItem, ContextItemType +from ..context.rendering import ContextItemRenderer +from ..nexent_agent import NexentAgent, _wrap_tool_with_monitoring +from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig +from .events import JiuwenEventAdapter + + +logger = logging.getLogger(__name__) + + +def _tool_schema(inputs): + """Convert existing tool parameter declarations into JSON Schema.""" + if isinstance(inputs, str): + inputs = json.loads(inputs) + inputs = inputs or {} + if inputs.get("type") == "object" and "properties" in inputs: + return inputs + properties, required = {}, [] + for name, specification in inputs.items(): + value = dict(specification) + nullable = value.pop("nullable", False) + value.pop("description_zh", None) + if value.get("type") == "any": + value.pop("type") + if value.get("type") == "array": + value.setdefault("items", {}) + if not nullable and "default" not in value: + required.append(name) + properties[name] = value + return {"type": "object", "properties": properties, "required": required} + + +class JiuwenAdapter(AgentCoreAdapter): + core_name = "jiuwen" + + def __init__(self, run_info): + super().__init__(run_info) + self.native_agent = None + self.resources = None + self.mcp = None + self.owner_id = f"nexent_{uuid.uuid4().hex}" + self.session_id = f"{self.owner_id}_session" + self.tool_configs = [] + self.visible_tool_names = set() + self.business_tools = {} + self.prefix_messages = [] + self._closed = False + self._tool_executions = [] + + @classmethod + def capabilities(cls): + return frozenset({"tools", "mcp", "skills", "streaming", "history", "cancellation"}) + + def validate(self): + config = self.run_info.agent_config + unsupported = [] + if config.worker_agents: + unsupported.append("worker_agents") + if config.external_a2a_agents: + unsupported.append("external_a2a_agents") + if config.enable_planning: + unsupported.append("enable_planning") + if config.verification_config.enabled or ( + config.verification_config.guardrail_config and config.verification_config.guardrail_config.enabled + ): + unsupported.append("verification_config") + if config.enable_protocol_repair_retry or config.output_protocol != "code_action": + unsupported.append("smolagents output protocol") + if config.provide_run_summary: + unsupported.append("provide_run_summary") + if config.context_manager_config is not None: + from ..context.policy import resolve_policy + + policy = resolve_policy(config.context_manager_config.policy_layers) + if policy.processing_mode.value != "passthrough": + unsupported.append("adaptive context compaction (disable enable_context_manager for Jiuwen)") + if config.model_name and any(model.cite_name == config.model_name and model.prompt_cache + for model in self.run_info.model_config_list): + unsupported.append("provider prompt_cache policy") + if unsupported: + raise UnsupportedAgentCoreConfig("Jiuwen does not yet support: " + ", ".join(unsupported)) + self.model_config = next( + (model for model in self.run_info.model_config_list if model.cite_name == config.model_name), None, + ) + if self.model_config is None: + raise ValueError(f"Model alias not found: {config.model_name}") + self.tool_configs = list(config.tools) + registry = {tool.name or tool.class_name: tool for tool in self.tool_configs} + if len(registry) != len(self.tool_configs): + raise ValueError("Duplicate tool names in agent configuration") + self.visible_tool_names = set(registry) + if config.prompt_tool_policy_snapshot is not None: + policy = PromptToolPolicySnapshot.from_mapping(config.prompt_tool_policy_snapshot) + effective = filter_effective_prompt_tools( + registry, enabled=set(policy.enabled), allowed=set(policy.allowed), + system_default_hidden=set(policy.system_default_hidden), policy_version=policy.policy_version, + tool_schema_version=policy.tool_schema_version, + ) + self.tool_configs = list(effective.execution_tools.values()) + self.visible_tool_names = set(effective.tools) + if any(tool.source == "mcp" for tool in self.tool_configs) and not self.run_info.mcp_host: + raise ValueError("Authorized MCP tools require resolved MCP hosts") + + def _observe_tool(self, name, arguments): + call_id = uuid.uuid4().hex + self.run_info.observer.add_message( + self.run_info.agent_config.name, ProcessType.TOOL, "", + tool_name=name, tool_arguments=arguments, tool_call_id=call_id, + ) + return self.run_info.observer.tool_call_context(call_id) + + def prepare(self): + from openjiuwen.core.foundation.tool import LocalFunction, ToolCard + from openjiuwen.core.single_agent import AgentCard + from openjiuwen.harness.factory import create_deep_agent + + from ..run_agent import _get_default_agent_thread_manager + + info = self.run_info + self.manager = info.thread_manager or _get_default_agent_thread_manager() + self.scope = info.cancellation_scope or RunCancellationScope(info.stop_event) + if self.scope.cancelled: + raise RunTerminated() + self.resources = NexentAgent( + observer=info.observer, model_config_list=info.model_config_list, stop_event=info.stop_event, + redis_client=info.redis_client, sandbox_config=info.sandbox_config, minio_client=info.minio_client, + conversation_id=info.conversation_id, user_id=info.user_id, tenant_id=info.tenant_id, + workspace_path=info.workspace_path, workspace_run_id=info.workspace_run_id, minio_files=info.minio_files, + cancellation_scope=self.scope, user_context=info.user_context, + ) + native_tools = [] + for config in self.tool_configs: + if config.source == "mcp" or config.class_name == "ParallelExecutorTool": + continue + tool = _wrap_tool_with_monitoring(self.resources.create_tool(config), info.agent_config.name) + name = config.name or tool.name + self.business_tools[name] = tool + if name in self.visible_tool_names: + native_tools.append(LocalFunction( + ToolCard(name=name, description=config.description or tool.description, + input_params=_tool_schema(tool.inputs)), self._business_handler(name, tool.forward), + )) + # Reuse file/sandbox infrastructure, never construct a smolagents Agent. + self.resources.agent = SimpleNamespace(tools=self.business_tools, python_executor=None) + self._bind_skill_sandbox() + self.query = self.resources._prepare_file_workspace(info.query) + prompt = self._compile_context() + rails = self._skill_rails() + model = self._create_model() + self.native_agent = create_deep_agent( + model, card=AgentCard(id=self.owner_id, name=info.agent_config.name, + description=info.agent_config.description), + tool_owner_id=self.owner_id, system_prompt=prompt, tools=native_tools, rails=rails, + max_iterations=info.agent_config.max_steps, workspace=info.workspace_path, + enable_task_loop=False, enable_sys_operation=False, enable_read_image_multimodal=False, + enable_model_anomaly_detection_rail=False, language="cn" if info.observer.lang == "zh" else "en", + parallel_tool_calls=True, + ) + if any(config.class_name == "ParallelExecutorTool" and + (config.name or "parallel_executor") in self.visible_tool_names for config in self.tool_configs): + self._bind_parallel_tool() + + def _business_handler(self, name, forward): + async def execute(**arguments): + if self.scope.cancelled: + raise asyncio.CancelledError() + with self._observe_tool(name, arguments): + if inspect.iscoroutinefunction(forward): + result = await forward(**arguments) + else: + execution = self.manager.submit( + "model-tool-io", ManagedTaskSpec(task_name=f"jiuwen-tool-{name}", owner=__name__), + forward, **arguments, + ) + self._tool_executions.append(execution) + try: + result = await asyncio.wrap_future(execution.future) + except asyncio.CancelledError: + self.manager.cancel(execution.execution_id, reason="Jiuwen run cancelled", wait_timeout=0, + mark_stuck_on_timeout=False) + raise + self.run_info.observer.add_message(self.run_info.agent_config.name, ProcessType.EXECUTION_LOGS, + str(result)) + return result + + return execute + + def _compile_context(self): + from ..run_agent import _get_authorized_context_items, _get_authorized_history + + config = self.run_info.agent_config + instructions = [config.instructions or ""] + context = [] + for item in _get_authorized_context_items(self.run_info) or (): + if item.type == ContextItemType.SYSTEM: + # Platform code-action instructions cannot be reused by a native tool-calling core. + if item.id in {"system:duty", "system:constraint", "system:few_shots", + "system:knowledge_scope_policy", "system:memory_tool_policy", + "system:automation_tool_policy", "system:sandbox_workspace_guidance"} or ( + not item.id.startswith("system:") and item.metadata.get("authority", "agent") == "agent" + ): + instructions.append(item.content.get("text", "")) + elif item.type not in {ContextItemType.TOOL, ContextItemType.SKILL, + ContextItemType.WORKER_AGENT, ContextItemType.EXTERNAL_AGENT, + ContextItemType.CURRENT_TASK}: + context.append(ContextItem.from_input(item)) + self.prefix_messages = ContextItemRenderer().render(context) + self.prefix_messages.extend( + {"role": history.role, "content": history.content} + for history in _get_authorized_history(self.run_info) or () + ) + return "\n\n".join(text for text in instructions if text) or config.description + + def _create_model(self): + from openjiuwen.core.foundation.llm import Model + from openjiuwen.core.foundation.llm.schema.config import ModelClientConfig, ModelRequestConfig + + from ...models.model_concurrency import model_concurrency_limiter + from ...utils.token_estimation import estimate_tokens_text + + config = self.model_config + prefix = self.prefix_messages + info = self.run_info + budget = getattr(info.agent_config.context_manager_config, "effective_input_limit_tokens", 0) + model_step = 0 + + def start_model_call(): + nonlocal model_step + model_step += 1 + info.observer.add_message(info.agent_config.name, ProcessType.STEP_COUNT, str(model_step)) + + def acquire(messages, tools): + if budget: + rendered = json.dumps([message.model_dump() if hasattr(message, "model_dump") else message + for message in messages], ensure_ascii=False, default=str) + schemas = json.dumps(tools or [], ensure_ascii=False, default=str) + if estimate_tokens_text(rendered + schemas) > budget: + raise UnsupportedAgentCoreConfig("Jiuwen input exceeds the resolved Nexent context budget") + if config.concurrency_limit is not None: + return model_concurrency_limiter.acquire( + (str(info.tenant_id or "default"), str(config.model_factory or "unknown"), config.model_name), + config.concurrency_limit, config.timeout_seconds or 300, info.stop_event, + ) + return None + + class RunModel(Model): + async def invoke(model_self, messages, **kwargs): + start_model_call() + messages = [*prefix, *messages] + permit = acquire(messages, kwargs.get("tools")) + try: + return await super().invoke(messages, **kwargs) + finally: + if permit: + permit.release() + + async def stream(model_self, messages, **kwargs): + start_model_call() + messages = [*prefix, *messages] + permit = acquire(messages, kwargs.get("tools")) + try: + async for chunk in super().stream(messages, **kwargs): + yield chunk + finally: + if permit: + permit.release() + + timeout = config.timeout_seconds or 300.0 + return RunModel( + ModelClientConfig( + client_provider="OpenAI", api_key=config.api_key, api_base=config.url, + auth_mode="api_key" if config.api_key else "none", + timeout=timeout, stream_first_chunk_timeout=timeout, stream_idle_timeout=min(timeout, 60), + max_retries=0, use_shared_llm_http_client=False, verify_ssl=config.ssl_verify is not False, + extensions={"request_extra_body": config.extra_body or {}}, + ), + ModelRequestConfig( + model=config.model_name, temperature=config.temperature, top_p=config.top_p, + max_tokens=self.run_info.agent_config.requested_output_tokens or config.max_output_tokens, + context_window=config.context_window_tokens, + reasoning={"mode": "enabled" if config.enable_thinking else "disabled", + "effort": config.reasoning_effort, "budget_tokens": config.reasoning_budget_tokens}, + ), + ) + + def _skill_rails(self): + from .skills import NexentSkillRail + + directories, names = set(), set() + for config in self.tool_configs: + if config.class_name not in {"ReadSkillMdTool", "RunSkillScriptTool", "ReadSkillConfigTool"}: + continue + parameters = config.params or {} + authorized = parameters.get("authorized_skill_names") + if authorized is None: + raise UnsupportedAgentCoreConfig("Jiuwen Skills require an explicit authorized skill snapshot") + if parameters.get("local_skills_dir"): + directories.add(parameters["local_skills_dir"]) + names.update(authorized) + return [NexentSkillRail(sorted(directories), sorted(names))] if directories and names else [] + + def _bind_skill_sandbox(self): + from ..sandbox import SandboxLevel, SandboxSkillScriptRunner, build_python_executor + + config = self.run_info.sandbox_config + if config is None or config.level == SandboxLevel.LOCAL: + return + executor = build_python_executor(config, logger, host_tools_exist=True, cancellation_scope=self.scope) + self.resources._sandbox_executors.append(executor) + self.resources.agent.python_executor = executor + runner = SandboxSkillScriptRunner( + executor, timeout_seconds=int(config.timeout_seconds), workspace_path=self.run_info.workspace_path, + network_enabled=not config.network_disabled, workspace_mapping=self.resources.workspace_mapping, + ) + self.resources._sandbox_skill_runners.append(runner) + for tool in self.business_tools.values(): + if getattr(tool, "name", None) == "run_skill_script": + tool.bind_execution_backend(runner, on_complete=lambda _result: self.resources._pull_file_workspace_from_sandbox()) + + def _bind_parallel_tool(self): + from openjiuwen.core.foundation.tool import LocalFunction, ToolCard + from openjiuwen.core.runner import Runner + + async def parallel(tasks): + async def invoke(task): + name = task["tool_name"] + if name == "parallel_executor": + raise ValueError("Recursive parallel executor is not allowed") + card = self.native_agent.ability_manager.get(name) + if card is None: + raise PermissionError("Parallel task references an unavailable tool") + tool = Runner.resource_mgr.get_tool(card.id) + return await tool.invoke(task.get("arguments", {})) + + with self._observe_tool("parallel_executor", {"tasks": tasks}): + return await asyncio.gather(*(invoke(task) for task in tasks)) + + card = ToolCard(name="parallel_executor", description="Execute independent authorized native tools concurrently.", + input_params={"type": "object", "properties": {"tasks": {"type": "array", "maxItems": 16, + "items": {"type": "object", "properties": {"tool_name": {"type": "string"}, + "arguments": {"type": "object"}}, "required": ["tool_name"]}}}, "required": ["tasks"]}) + self.native_agent.ability_manager.add_ability(card, LocalFunction(card, parallel)) + + def run(self): + try: + asyncio.run(self._run_async()) + except asyncio.CancelledError: + raise RunTerminated() from None + + async def _run_async(self): + from openjiuwen.core.runner import Runner + + from .mcp import JiuwenMCPCollection + + info = self.run_info + current = asyncio.current_task() + loop = asyncio.get_running_loop() + token = self.scope.register_closer(lambda: loop.call_soon_threadsafe(current.cancel)) + + async def watch_stop(): + while not info.stop_event.is_set(): + await asyncio.sleep(0.05) + current.cancel() + + watcher = asyncio.create_task(watch_stop()) + events = JiuwenEventAdapter(info.observer, info.agent_config.name) + try: + mcp_tools = [tool for tool in self.tool_configs if tool.source == "mcp" and + (tool.name or tool.class_name) in self.visible_tool_names] + if mcp_tools: + self.mcp = JiuwenMCPCollection( + authorized_tools=mcp_tools, observe=self._observe_tool, manager=self.manager, + emit_result=lambda result: info.observer.add_message( + info.agent_config.name, ProcessType.EXECUTION_LOGS, str(result)), + user_context=info.user_context, + server_parameters=info.mcp_host, cancellation_scope=self.scope, + tool_timeout_seconds=info.mcp_tool_timeout_seconds, close_timeout_seconds=info.mcp_close_timeout_seconds, + connect_timeout_seconds=info.mcp_request_timeout_seconds or 10.0, + ) + collection = self.mcp.__enter__() + for tool in collection.tools: + self.native_agent.ability_manager.add_ability(tool.card, tool) + info.observer.add_message(info.agent_config.name, ProcessType.AGENT_NEW_RUN, self.query) + for event in info.agent_config.pre_run_tool_events: + info.observer.add_message(info.agent_config.name, ProcessType.OTHER, json.dumps(event, ensure_ascii=False)) + async with aclosing(Runner.run_agent_streaming( + self.native_agent, {"query": self.query, "conversation_id": self.session_id}, session=self.session_id, + )) as stream: + async for chunk in stream: + if self.scope.cancelled: + raise asyncio.CancelledError() + if self.mcp and self.mcp.terminal_error: + raise self.mcp.terminal_error + events.emit(chunk) + if self.scope.cancelled: + raise asyncio.CancelledError() + events.finish() + finally: + watcher.cancel() + await asyncio.gather(watcher, return_exceptions=True) + self.scope.unregister_closer(token) + try: + await Runner.release(self.session_id) + finally: + if self.native_agent is not None: + for card in self.native_agent.ability_manager.list(): + Runner.resource_mgr.remove_tool(card.id) + + def close(self): + if self._closed: + return + self._closed = True + if self.native_agent is not None: + from openjiuwen.core.runner import Runner + + for card in self.native_agent.ability_manager.list(): + if Runner.resource_mgr.get_tool(card.id) is not None: + Runner.resource_mgr.remove_tool(card.id) + for execution in self._tool_executions: + if not execution.future.done(): + self.manager.cancel(execution.execution_id, reason="Jiuwen tool cleanup", + wait_timeout=self.run_info.mcp_close_timeout_seconds, mark_stuck_on_timeout=True) + try: + if self.mcp: + self.mcp.close() + finally: + if self.resources: + try: + self.resources._finalize_file_workspace() + finally: + self.resources._cleanup_file_workspace() + self.resources._cleanup_sandbox() diff --git a/sdk/nexent/core/agents/core_adapters/mcp.py b/sdk/nexent/core/agents/core_adapters/mcp.py new file mode 100644 index 0000000000..a35a58f724 --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/mcp.py @@ -0,0 +1,160 @@ +"""Jiuwen native MCP tools with Nexent managed session ownership.""" + +import asyncio +import logging +from contextlib import AsyncExitStack, nullcontext +from types import SimpleNamespace + +from openjiuwen.core.foundation.tool.mcp.base import McpServerConfig, MCPTool, McpToolCard +from openjiuwen.core.foundation.tool.mcp.client.streamable_http_client import StreamableHttpClient + +from ....consts.mcp_errors import MCPConnectionTimeoutError, MCPToolTimeoutError +from ...concurrency import ManagedTaskSpec +from ..managed_mcp import ManagedMCPToolCollection +from ..tool_user_context import USER_CONTEXT_FIELDS + + +logger = logging.getLogger(__name__) + + +class JiuwenMCPCollection(ManagedMCPToolCollection): + """Reuse bounded lifecycle ownership, never instantiate a smolagents MCP adapter.""" + + def __init__(self, *, authorized_tools, observe, user_context=None, emit_result=None, **kwargs): + super().__init__(**kwargs) + self.authorized_tools = authorized_tools + self.observe = observe + self.emit_result = emit_result + self.terminal_error = None + self.user_context = dict(user_context or {}) + + async def _run_async_session(self): + from ..run_agent import _normalize_mcp_config + + async with AsyncExitStack() as stack: + available = {} + for index, raw in enumerate(self.server_parameters): + parameters = _normalize_mcp_config(raw) + headers = dict(parameters.get("headers") or {}) + if parameters.get("authorization"): + headers["Authorization"] = parameters["authorization"] + config = McpServerConfig( + server_name=f"nexent_{index}", server_path=parameters["url"], + client_type=parameters["transport"], auth_headers=headers, + ) + client = _ConfiguredNativeClient(config, parameters.get("httpx_client_factory")) + # Connect and disconnect in this owner task: MCP uses task-bound cancel scopes. + stack.push_async_callback(client.disconnect, timeout=self.close_timeout_seconds) + if not await client.connect(timeout=self.connect_timeout_seconds): + raise MCPConnectionTimeoutError("Jiuwen MCP connection failed") + for card in await client.list_tools(timeout=self.connect_timeout_seconds): + if card.name in available: + available[card.name] = None + else: + available[card.name] = (client, card) + + tools = [] + for tool_config in self.authorized_tools: + match = available.get(tool_config.class_name) + if match is None: + raise ValueError(f"Authorized MCP tool missing or ambiguous: {tool_config.class_name}") + client, remote_card = match + properties = remote_card.input_params.get("properties", {}) + hidden = [name for name in USER_CONTEXT_FIELDS if name in properties] + proxy = _ManagedNativeClient(self, client, remote_card.name, hidden) + card = remote_card.model_copy(deep=True) + for name in hidden: + card.input_params["properties"].pop(name, None) + card.input_params["required"] = [name for name in card.input_params.get("required", []) + if name not in hidden] + card.name = tool_config.name or tool_config.class_name + if tool_config.description: + card.description = tool_config.description + tools.append(_ObservedMCPTool(proxy, card, self.observe, self.emit_result)) + + close_event = asyncio.Event() + with self._lock: + self._async_close_event = close_event + self.tools = tools + self._public_collection = SimpleNamespace(tools=tools) + if self._close_requested.is_set(): + close_event.set() + self._ready.set() + await close_event.wait() + + +class _ManagedNativeClient: + def __init__(self, owner, client, remote_name, hidden_fields=()): + self.owner, self.client, self.remote_name = owner, client, remote_name + self.hidden_fields = hidden_fields + + async def call_tool(self, tool_name, arguments): + owner = self.owner + if owner.cancellation_scope.cancelled: + raise asyncio.CancelledError() + arguments = dict(arguments) + for name in self.hidden_fields: + arguments[name] = owner.user_context.get(name) + execution = owner.manager.submit( + "model-tool-io", + ManagedTaskSpec(task_name="jiuwen-mcp-tool", owner=__name__, close_hook=owner.request_close), + owner._call_tool, self.client, self.remote_name, arguments, + ) + try: + return await asyncio.wait_for(asyncio.wrap_future(execution.future), owner.tool_timeout_seconds) + except asyncio.TimeoutError as exc: + logger.warning("event=mcp_tool_timeout tool_name=%s timeout_seconds=%s phase=execution", + tool_name, owner.tool_timeout_seconds) + owner.terminal_error = MCPToolTimeoutError("Jiuwen MCP tool deadline exceeded") + owner.request_close() + owner.manager.cancel(execution.execution_id, reason="MCP tool deadline exceeded", wait_timeout=0, + mark_stuck_on_timeout=False) + raise owner.terminal_error from exc + except asyncio.CancelledError: + owner.request_close() + raise + + +class _ObservedMCPTool(MCPTool): + def __init__(self, client, card, observe, emit_result): + super().__init__(client, card) + self._observe = observe + self._emit_result = emit_result + + async def invoke(self, inputs, **kwargs): + with self._observe(self.card.name, inputs) or nullcontext(): + result = await super().invoke(inputs, **kwargs) + if self._emit_result: + self._emit_result(result) + return result + + +class _ConfiguredNativeClient(StreamableHttpClient): + """Preserve Nexent transport policy while retaining native Jiuwen MCP operations.""" + + def __init__(self, config, http_client_factory): + super().__init__(config) + self._transport = config.client_type + self._http_client_factory = http_client_factory + + async def connect(self, *, timeout): + from mcp import ClientSession + from mcp.client.sse import sse_client + from mcp.client.streamable_http import streamable_http_client + + options = {"headers": self._auth_headers, "timeout": timeout} + if self._http_client_factory is not None: + options["httpx_client_factory"] = self._http_client_factory + transport = sse_client if self._transport == "sse" else streamable_http_client + self._client = transport(self._server_path, **options) + streams = await self._exit_stack.enter_async_context(self._client) + self._read, self._write, *_ = streams + self._session = await self._exit_stack.enter_async_context(ClientSession(self._read, self._write)) + await self._session.initialize() + self._is_disconnected = False + return True + + async def list_tools(self, *, timeout): + response = await self._session.list_tools() + return [McpToolCard(name=tool.name, server_name=self._name, description=tool.description or "", + input_params=tool.inputSchema) for tool in response.tools] diff --git a/sdk/nexent/core/agents/core_adapters/skills.py b/sdk/nexent/core/agents/core_adapters/skills.py new file mode 100644 index 0000000000..967a83991f --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/skills.py @@ -0,0 +1,33 @@ +"""Load authorized skill snapshots with Jiuwen's native skill rail.""" + +from pathlib import Path + +from openjiuwen.harness.rails.skills.skill_use_rail import SkillUseRail + + +class NexentSkillRail(SkillUseRail): + """Keep native skill discovery/prompt lifecycle without adding host shell tools.""" + + def __init__(self, directories: list[str], names: list[str]): + super().__init__(directories, enabled_skills=names, skill_mode="all", include_tools=False, enable_cache=False) + self._roots = tuple(Path(directory).resolve() for directory in directories) + self._names = frozenset(names) + + def init(self, agent): + # Authorized Nexent read/run handlers are already native abilities. + # The upstream init would additionally register an unscoped skill tool. + self.system_prompt_builder = agent.system_prompt_builder + self.attachment_manager = agent.prompt_attachment_manager + + async def _read_skill_text(self, path: Path, *, head=None): + path = path.resolve() + if path.name != "SKILL.md" or path.parent.name not in self._names: + raise PermissionError("Skill is outside the authorized snapshot") + if not any(path.is_relative_to(root) for root in self._roots): + raise PermissionError("Skill path escapes the authorized snapshot") + text = path.read_text(encoding="utf-8-sig") + return "\n".join(text.splitlines()[:head]) if head is not None else text + + async def _load_yaml(self, path: Path): + text = await self._read_skill_text(path) + return self._split_front_matter(text) or (None, text) diff --git a/sdk/nexent/core/agents/core_adapters/smolagents.py b/sdk/nexent/core/agents/core_adapters/smolagents.py new file mode 100644 index 0000000000..9e08520e37 --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/smolagents.py @@ -0,0 +1,31 @@ +"""Compatibility adapter for the existing smolagents execution path.""" + +from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig + + +class SmolagentsAdapter(AgentCoreAdapter): + core_name = "smolagents" + + def __init__(self, run_info, *, executor=None): + super().__init__(run_info) + self.executor = executor + + @classmethod + def capabilities(cls): + return frozenset({"tools", "mcp", "skills", "workers", "a2a", "planning", "verification"}) + + def validate(self): + def validate_tree(config): + for child in getattr(config, "worker_agents", ()): + if getattr(child, "agent_core", "smolagents") != self.core_name: + raise UnsupportedAgentCoreConfig("Mixed-core worker agents are not supported") + validate_tree(child) + + validate_tree(self.run_info.agent_config) + + def run(self): + if self.executor is not None: + return self.executor(self.run_info) + from ..run_agent import _run_smolagents + + _run_smolagents(self.run_info) diff --git a/sdk/nexent/core/agents/run_agent.py b/sdk/nexent/core/agents/run_agent.py index 765215f784..4b950f5c46 100644 --- a/sdk/nexent/core/agents/run_agent.py +++ b/sdk/nexent/core/agents/run_agent.py @@ -365,12 +365,12 @@ def agent_run_thread(agent_run_info: AgentRunInfo): def _agent_run_thread(agent_run_info: AgentRunInfo): + mcp_host = getattr(agent_run_info, "mcp_host", None) try: # Keep the runner compatible with legacy/lightweight AgentRunInfo # stand-ins used by integrations. Full AgentRunInfo instances always # define this field, but its absence must not prevent a run that does # not need tool-side authorization context. - user_context = getattr(agent_run_info, "user_context", None) set_monitoring_capacity_snapshot( getattr(agent_run_info, "capacity_snapshot", None) ) @@ -379,11 +379,97 @@ def _agent_run_thread(agent_run_info: AgentRunInfo): ) _emit_uncertainty_reserve_warning(agent_run_info) mcp_host = agent_run_info.mcp_host - if mcp_host is None or len(mcp_host) == 0: + from .core_adapters import AgentCoreFactory + + adapter = AgentCoreFactory.create(agent_run_info, smolagents_executor=_run_smolagents) + try: + adapter.validate() + adapter.prepare() + adapter.run() + finally: + adapter.close() + + agent_run_info.attempt_outcome = "stopped" if agent_run_info.stop_event.is_set() else "completed" + except (RunTerminated, CancelledError): + agent_run_info.attempt_outcome = "stopped" + except (ModelInvocationTerminalError, ModelOutputProtocolExhaustedError): + agent_run_info.attempt_outcome = "failed" + raise + except Exception as e: + agent_run_info.attempt_outcome = "failed" + agent_run_info.observer.add_message( + "", + ProcessType.FINAL_ANSWER, + _agent_run_error_message(agent_run_info, mcp_host, e), + ) + raise ValueError(f"Error in agent_run_thread: {e}") + finally: + # Agent construction, MCP setup, and executor initialization can fail + # before NexentAgent.agent_run_with_observer() enters its own cleanup + # block. This idempotent outer guard owns the complete worker lifetime. + cleanup_run_workspace( + getattr(agent_run_info, "workspace_path", None), + getattr(agent_run_info, "workspace_run_id", None), + logger, + ) + + +def _run_smolagents(agent_run_info: AgentRunInfo): + """Execute the preserved smolagents resource and observer path.""" + user_context = getattr(agent_run_info, "user_context", None) + mcp_host = agent_run_info.mcp_host + if mcp_host is None or len(mcp_host) == 0: + nexent = NexentAgent( + observer=agent_run_info.observer, + model_config_list=agent_run_info.model_config_list, + stop_event=agent_run_info.stop_event, + redis_client=agent_run_info.redis_client, + user_context=user_context, + sandbox_config=getattr(agent_run_info, "sandbox_config", None), + minio_client=getattr(agent_run_info, "minio_client", None), + conversation_id=agent_run_info.conversation_id, + user_id=agent_run_info.user_id, + tenant_id=getattr(agent_run_info, "tenant_id", None), + workspace_path=getattr(agent_run_info, "workspace_path", None), + workspace_run_id=getattr(agent_run_info, "workspace_run_id", None), + minio_files=getattr(agent_run_info, "minio_files", None), + cancellation_scope=agent_run_info.cancellation_scope, + ) + agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. + agent_run_info.agent_config, + context_items_override=_get_authorized_context_items(agent_run_info), + ) + nexent.set_agent(agent) + + nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) + try: + nexent.agent_run_with_observer( + query=agent_run_info.query, + reset=False, + additional_args=build_run_additional_args(agent_run_info), + ) + finally: + _log_memory_value_assessment(agent) + else: + agent_run_info.observer.add_message("", ProcessType.AGENT_NEW_RUN, "") + mcp_client_list = [_normalize_mcp_config(item) for item in mcp_host] + mcp_cancellation_scope = agent_run_info.cancellation_scope or RunCancellationScope( + agent_run_info.stop_event + ) + + with ManagedMCPToolCollection( + manager=agent_run_info.thread_manager or _get_default_agent_thread_manager(), + server_parameters=mcp_client_list, + cancellation_scope=mcp_cancellation_scope, + tool_timeout_seconds=agent_run_info.mcp_tool_timeout_seconds, + close_timeout_seconds=agent_run_info.mcp_close_timeout_seconds, + connect_timeout_seconds=_resolve_mcp_request_timeout_seconds(agent_run_info), + ) as tool_collection: nexent = NexentAgent( observer=agent_run_info.observer, model_config_list=agent_run_info.model_config_list, stop_event=agent_run_info.stop_event, + mcp_tool_collection=tool_collection, redis_client=agent_run_info.redis_client, user_context=user_context, sandbox_config=getattr(agent_run_info, "sandbox_config", None), @@ -411,77 +497,7 @@ def _agent_run_thread(agent_run_info: AgentRunInfo): ) finally: _log_memory_value_assessment(agent) - else: - agent_run_info.observer.add_message("", ProcessType.AGENT_NEW_RUN, "") - mcp_client_list = [_normalize_mcp_config(item) for item in mcp_host] - mcp_cancellation_scope = agent_run_info.cancellation_scope or RunCancellationScope( - agent_run_info.stop_event - ) - - with ManagedMCPToolCollection( - manager=agent_run_info.thread_manager or _get_default_agent_thread_manager(), - server_parameters=mcp_client_list, - cancellation_scope=mcp_cancellation_scope, - tool_timeout_seconds=agent_run_info.mcp_tool_timeout_seconds, - close_timeout_seconds=agent_run_info.mcp_close_timeout_seconds, - connect_timeout_seconds=_resolve_mcp_request_timeout_seconds(agent_run_info), - ) as tool_collection: - nexent = NexentAgent( - observer=agent_run_info.observer, - model_config_list=agent_run_info.model_config_list, - stop_event=agent_run_info.stop_event, - mcp_tool_collection=tool_collection, - redis_client=agent_run_info.redis_client, - user_context=user_context, - sandbox_config=getattr(agent_run_info, "sandbox_config", None), - minio_client=getattr(agent_run_info, "minio_client", None), - conversation_id=agent_run_info.conversation_id, - user_id=agent_run_info.user_id, - tenant_id=getattr(agent_run_info, "tenant_id", None), - workspace_path=getattr(agent_run_info, "workspace_path", None), - workspace_run_id=getattr(agent_run_info, "workspace_run_id", None), - minio_files=getattr(agent_run_info, "minio_files", None), - cancellation_scope=agent_run_info.cancellation_scope, - ) - agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. - agent_run_info.agent_config, - context_items_override=_get_authorized_context_items(agent_run_info), - ) - nexent.set_agent(agent) - - nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) - try: - nexent.agent_run_with_observer( - query=agent_run_info.query, - reset=False, - additional_args=build_run_additional_args(agent_run_info), - ) - finally: - _log_memory_value_assessment(agent) - agent_run_info.attempt_outcome = "stopped" if agent_run_info.stop_event.is_set() else "completed" - except (RunTerminated, CancelledError): - agent_run_info.attempt_outcome = "stopped" - except (ModelInvocationTerminalError, ModelOutputProtocolExhaustedError): - agent_run_info.attempt_outcome = "failed" - raise - except Exception as e: - agent_run_info.attempt_outcome = "failed" - agent_run_info.observer.add_message( - "", - ProcessType.FINAL_ANSWER, - _agent_run_error_message(agent_run_info, mcp_host, e), - ) - raise ValueError(f"Error in agent_run_thread: {e}") - finally: - # Agent construction, MCP setup, and executor initialization can fail - # before NexentAgent.agent_run_with_observer() enters its own cleanup - # block. This idempotent outer guard owns the complete worker lifetime. - cleanup_run_workspace( - getattr(agent_run_info, "workspace_path", None), - getattr(agent_run_info, "workspace_run_id", None), - logger, - ) async def agent_run( diff --git a/sdk/nexent/core/concurrency/cancellation.py b/sdk/nexent/core/concurrency/cancellation.py index 0a7b6baf6f..b6350e8585 100644 --- a/sdk/nexent/core/concurrency/cancellation.py +++ b/sdk/nexent/core/concurrency/cancellation.py @@ -1,6 +1,7 @@ from __future__ import annotations import threading +import time import uuid from dataclasses import dataclass from typing import Callable @@ -15,21 +16,61 @@ class ResourceToken: value: str +class _LinkedStopEvent(threading.Event): + """Observe parent cancellation while writing only to the local event.""" + + def __init__(self, parent: threading.Event, local: threading.Event | None = None): + super().__init__() + self._parent = parent + self._local = local if local is not None else threading.Event() + + def set(self) -> None: + self._local.set() + + def clear(self) -> None: + self._local.clear() + + def is_set(self) -> bool: + return self._local.is_set() or self._parent.is_set() + + def wait(self, timeout: float | None = None) -> bool: + deadline = None if timeout is None else time.monotonic() + timeout + while not self.is_set(): + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + return False + self._local.wait(0.05 if remaining is None else min(0.05, remaining)) + return True + + class RunCancellationScope: """Thread-safe run cancellation with active resource close hooks.""" - def __init__(self, stop_event: threading.Event | None = None): - self.stop_event = stop_event or threading.Event() + def __init__( + self, + stop_event: threading.Event | None = None, + *, + parent_stop_event: threading.Event | None = None, + ): + self.stop_event = ( + _LinkedStopEvent(parent_stop_event, stop_event) + if parent_stop_event is not None + else stop_event if stop_event is not None else threading.Event() + ) self._lock = threading.Lock() self._closers: dict[ResourceToken, Callable[[], None]] = {} self._cancelled = False @property def cancelled(self) -> bool: + if self.stop_event.is_set(): + self.cancel() with self._lock: return self._cancelled def register_closer(self, close: Callable[[], None]) -> ResourceToken: + if self.stop_event.is_set(): + self.cancel() token = ResourceToken(uuid.uuid4().hex) close_immediately = False with self._lock: diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 4f95a9ac31..a99eec4936 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -74,6 +74,9 @@ override-dependencies = [ ] [project.optional-dependencies] +jiuwen = [ + "openjiuwen==0.1.18", +] quality = [ "ruff>=0.9.0", "pytest>=8.1.0" diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index b2a482f486..ff379f319d 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -2503,6 +2503,7 @@ async def test_create_agent_config_basic(self): # Verify that AgentConfig was called correctly mock_agent_config.assert_called_once_with( + agent_core="smolagents", name="test_agent", display_name=None, description="test description", @@ -2594,6 +2595,7 @@ async def test_create_agent_config_with_sub_agents(self): # Verify that AgentConfig was called correctly, including sub-agents mock_agent_config.assert_called_once_with( + agent_core="smolagents", name="test_agent", display_name=None, description="test description", @@ -2946,6 +2948,7 @@ async def test_create_agent_config_model_id_none(self): result = await create_agent_config("agent_1", "tenant_1", "user_1", "zh", "test query") mock_agent_config.assert_called_with( + agent_core="smolagents", name="test_agent", display_name=None, description="test description", diff --git a/test/backend/database/test_agent_db.py b/test/backend/database/test_agent_db.py index a1f099938c..9a484c3fc1 100644 --- a/test/backend/database/test_agent_db.py +++ b/test/backend/database/test_agent_db.py @@ -154,6 +154,7 @@ def _default_to_ordinary_agent(monkeypatch): class MockAgent: def __init__(self): self.agent_id = 1 + self.agent_core = "smolagents" self.name = "test_agent" self.display_name = "test_agent" self.tenant_id = "tenant1" From 2a1edf7dfd73635160ab956f7a6c778fbdb2b9cb Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Mon, 12 Oct 2026 20:25:21 +0800 Subject: [PATCH 02/26] fix(agent-core): configure native MCP HTTP client explicitly --- sdk/nexent/core/agents/core_adapters/mcp.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/sdk/nexent/core/agents/core_adapters/mcp.py b/sdk/nexent/core/agents/core_adapters/mcp.py index a35a58f724..9c55e36179 100644 --- a/sdk/nexent/core/agents/core_adapters/mcp.py +++ b/sdk/nexent/core/agents/core_adapters/mcp.py @@ -138,15 +138,22 @@ def __init__(self, config, http_client_factory): self._http_client_factory = http_client_factory async def connect(self, *, timeout): + import httpx from mcp import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client + from mcp.shared._httpx_utils import create_mcp_http_client options = {"headers": self._auth_headers, "timeout": timeout} if self._http_client_factory is not None: options["httpx_client_factory"] = self._http_client_factory - transport = sse_client if self._transport == "sse" else streamable_http_client - self._client = transport(self._server_path, **options) + if self._transport == "sse": + self._client = sse_client(self._server_path, **options) + else: + factory = self._http_client_factory or create_mcp_http_client + http_client = factory(headers=self._auth_headers, timeout=httpx.Timeout(timeout, read=300)) + await self._exit_stack.enter_async_context(http_client) + self._client = streamable_http_client(self._server_path, http_client=http_client) streams = await self._exit_stack.enter_async_context(self._client) self._read, self._write, *_ = streams self._session = await self._exit_stack.enter_async_context(ClientSession(self._read, self._write)) From b7ea8642e65def75707db025e4328e262eb11f73 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Mon, 12 Oct 2026 20:37:56 +0800 Subject: [PATCH 03/26] fix(agent-core): isolate legacy Jiuwen import workarounds --- backend/adapters/jiuwen_sdk_adapter.py | 40 +++++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/backend/adapters/jiuwen_sdk_adapter.py b/backend/adapters/jiuwen_sdk_adapter.py index 4adb30bd47..3b29b2801b 100644 --- a/backend/adapters/jiuwen_sdk_adapter.py +++ b/backend/adapters/jiuwen_sdk_adapter.py @@ -1,10 +1,13 @@ """ openjiuwen SDK adapter for Nexent. -This module works around circular import bugs in openjiuwen 0.1.13 by: +For versions before 0.1.18, this module works around circular imports by: 1. Stubbing react_agent_evolve (causes the circular import) 2. Installing a meta-path finder that blocks broken __init__.py files - 3. All of the above runs at MODULE LOAD TIME (before openjiuwen imports) + 3. Installing these workarounds before openjiuwen imports + +Native versions use their real packages so the Agent Core can coexist with +prompt optimization in the same service process. The adapters/__init__.py wraps the import in try/except so the server starts even when openjiuwen is not installed. @@ -17,8 +20,16 @@ import os import sys import types +from importlib.metadata import PackageNotFoundError, version from typing import Any, List, Literal, Optional, Tuple +from packaging.version import Version + +try: + _needs_legacy_bypass = Version(version("openjiuwen")) < Version("0.1.18") +except PackageNotFoundError: + _needs_legacy_bypass = False + # ---------------------------------------------------------------------- # MUST install bypasser + stubs before any openjiuwen import. # The module-level `from openjiuwen.dev_tools.tune.base import ...` below @@ -28,7 +39,7 @@ # Stub react_agent_evolve so single_agent.__init__.py can import it without # hitting the circular core.operator dependency. -if "openjiuwen.core.single_agent.agents.react_agent_evolve" not in sys.modules: +if _needs_legacy_bypass and "openjiuwen.core.single_agent.agents.react_agent_evolve" not in sys.modules: _stub = types.ModuleType("openjiuwen.core.single_agent.agents.react_agent_evolve") _stub.__file__ = "" @@ -55,7 +66,7 @@ class _DummyReActAgentEvolve: # noqa: N801 ("dashscope", {}), ("pdfplumber", {}), ]: - if _name not in sys.modules: + if _needs_legacy_bypass and _name not in sys.modules: _mod = types.ModuleType(_name) _mod.__path__ = [] for _k, _v in _attrs.items(): @@ -63,7 +74,7 @@ class _DummyReActAgentEvolve: # noqa: N801 sys.modules[_name] = _mod for _name in ["pymilvus.client", "pymilvus.client.utils"]: - if _name not in sys.modules: + if _needs_legacy_bypass and _name not in sys.modules: _m = types.ModuleType(_name) _m.__path__ = [] if _name == "pymilvus.client.utils": @@ -77,7 +88,7 @@ class _DummyReActAgentEvolve: # noqa: N801 ("dashscope.common", {"REQUEST_TIMEOUT_KEYWORD": "timeout"}), ("dashscope.common.constants", {"REQUEST_TIMEOUT_KEYWORD": "timeout"}), ]: - if _name not in sys.modules: + if _needs_legacy_bypass and _name not in sys.modules: _m = types.ModuleType(_name) _m.__path__ = [] for _k, _v in _attrs.items(): @@ -175,23 +186,24 @@ def __getattr__(self, name: str) -> Any: raise AttributeError(name) -# Install the bypasser into sys.meta_path so it intercepts openjiuwen imports. -for _finder in sys.meta_path: - if isinstance(_finder, _JiuwenInitBypasser): - break -else: - sys.meta_path.insert(0, _JiuwenInitBypasser()) +# Install the finder only for the versions that require the import workaround. +if _needs_legacy_bypass: + for _finder in sys.meta_path: + if isinstance(_finder, _JiuwenInitBypasser): + break + else: + sys.meta_path.insert(0, _JiuwenInitBypasser()) # No-op: bypasser is already installed above. # Kept for backward compatibility with code that calls it. -_bypasser_installed = True +_bypasser_installed = _needs_legacy_bypass def _install_jiuwen_bypasser() -> bool: """No-op: bypasser is installed at module load time. Kept for compatibility.""" return True -# Now safe to import openjiuwen — bypasser is active. +# Import native packages directly unless the legacy workaround is required. from openjiuwen.dev_tools.tune.base import Case as _Case, EvaluatedCase as _EvaluatedCase From 068dc369154668c75ca9847fcfaa56043fe8c34c Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Tue, 13 Oct 2026 17:37:11 +0800 Subject: [PATCH 04/26] feat(agent-core): select Jiuwen in agent configuration UI --- backend/management/services/agent/service.py | 3 ++ .../advanced/GuardrailConfigContent.tsx | 4 ++ .../components/agent-core-selector.tsx | 49 +++++++++++++++++++ .../[agentId]/components/agent-run-policy.tsx | 15 ++++-- .../components/collaborative-agent.tsx | 6 +-- frontend/lib/agentCore.ts | 33 +++++++++++++ frontend/public/locales/en/common.json | 5 +- frontend/public/locales/zh/common.json | 5 +- frontend/services/agentConfigService.ts | 3 ++ frontend/stores/agentStore.ts | 8 ++- frontend/types/agentConfig.ts | 5 ++ 11 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx create mode 100644 frontend/lib/agentCore.ts diff --git a/backend/management/services/agent/service.py b/backend/management/services/agent/service.py index a2f9835c89..04ae174a10 100644 --- a/backend/management/services/agent/service.py +++ b/backend/management/services/agent/service.py @@ -816,6 +816,9 @@ async def update_agent_info_impl( if request.is_main_agent is not None else True, "provide_run_summary": request.provide_run_summary, + "enable_context_manager": request.enable_context_manager + if request.enable_context_manager is not None + else True, "allow_chat_metadata": request.allow_chat_metadata if request.allow_chat_metadata is not None else False, diff --git a/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx b/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx index 9206521720..914c826f9d 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx @@ -172,6 +172,9 @@ function useGuardrailConfigState() { export function GuardrailConfigActions() { const { t } = useTranslation("common"); const { config, updateConfig } = useGuardrailConfigState(); + const disabled = useAgentStore( + (state) => state.isReadOnly || state.editedAgent?.agent_core === "jiuwen" + ); return ( updateConfig({ ...config, enabled })} size="small" /> diff --git a/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx b/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx new file mode 100644 index 0000000000..4d5109542e --- /dev/null +++ b/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Alert, Select, Switch } from "antd"; +import { useTranslation } from "react-i18next"; +import { useAgentReadOnly } from "@/hooks/agent/useAgentReadOnly"; +import { useAgentStore } from "@/stores/agentStore"; +import type { AgentCore } from "@/types/agentConfig"; + +export default function AgentCoreSelector() { + const { t } = useTranslation("common"); + const agent = useAgentStore((state) => state.editedAgent); + const update = useAgentStore((state) => state.updateAgentConfig); + const readOnly = useAgentReadOnly(); + if (!agent) return null; + const core = agent.agent_core ?? "smolagents"; + return ( +
+ + + id="agent-core" + aria-label={t("agent.core.label")} + value={core} + disabled={readOnly} + options={[ + { value: "smolagents", label: "smolagents" }, + { value: "jiuwen", label: "Jiuwen" }, + ]} + onChange={(agent_core) => update({ agent_core })} + /> + {core === "jiuwen" && ( + + )} +
+ + + update({ enable_context_manager }) + } + /> +
+
+ ); +} diff --git a/frontend/app/[locale]/agents/[agentId]/components/agent-run-policy.tsx b/frontend/app/[locale]/agents/[agentId]/components/agent-run-policy.tsx index c0bb22c54e..68f5ca01d7 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/agent-run-policy.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/agent-run-policy.tsx @@ -7,6 +7,7 @@ import { useAgentStore } from "@/stores/agentStore"; import { useAgentReadOnly } from "@/hooks/agent/useAgentReadOnly"; import { DEFAULT_AGENT_VERIFICATION_CONFIG } from "@/types/agentConfig"; import { AgentDeploymentOptions } from "./agent-deployment"; +import AgentCoreSelector from "./agent-core-selector"; interface AgentRunPolicyProps { highFidelity?: boolean; @@ -17,6 +18,7 @@ export function AgentProtocolRepairOption() { const editedAgent = useAgentStore((state) => state.editedAgent!); const updateAgent = useAgentStore((state) => state.updateAgentConfig); const isReadOnly = useAgentReadOnly(); + const coreUnsupported = editedAgent.agent_core === "jiuwen"; return ( updateAgent({ enable_protocol_repair_retry: checked }) } @@ -42,10 +44,12 @@ export default function AgentRunPolicy({ const editedAgent = useAgentStore((state) => state.editedAgent!); const updateAgent = useAgentStore((state) => state.updateAgentConfig); const isReadOnly = useAgentReadOnly(); + const coreUnsupported = editedAgent.agent_core === "jiuwen"; if (highFidelity) { return (
+
updateAgent({ provide_run_summary: event.target.checked }) } @@ -104,7 +108,7 @@ export default function AgentRunPolicy({ updateAgent({ verification_config: { @@ -125,6 +129,7 @@ export default function AgentRunPolicy({ return (
+ {/* Max Steps */} @@ -175,6 +180,7 @@ export default function AgentRunPolicy({ updateAgent({ provide_run_summary: checked }) } @@ -204,7 +210,7 @@ export default function AgentRunPolicy({ > updateAgent({ enable_protocol_repair_retry: checked }) } @@ -219,6 +225,7 @@ export default function AgentRunPolicy({ > updateAgent({ verification_config: { diff --git a/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx b/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx index e340c8d77d..b9ed43474f 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx @@ -72,7 +72,7 @@ export function CollaborativeAgentList({ - {label} · {agents.length} + {label} 路 {agents.length} {agents.map((agent) => ( state.updateExternalSubAgentIds ); - const isReadOnly = useAgentReadOnly(); + const isReadOnly = useAgentReadOnly() || editedAgent?.agent_core === "jiuwen"; const { invalidate: invalidateExternalAgents } = useExternalAgents(); const externalSubAgentIdList = editedAgent?.external_sub_agent_id_list || []; @@ -218,7 +218,7 @@ export default function CollaborativeAgent({ const updateExternalSubAgentIds = useAgentStore( (state) => state.updateExternalSubAgentIds ); - const isReadOnly = useAgentReadOnly(); + const isReadOnly = useAgentReadOnly() || editedAgent?.agent_core === "jiuwen"; const { availableAgents: internalAgents } = usePublishedAgentList(); const { availableAgents: externalAgents } = useExternalAgents(); diff --git a/frontend/lib/agentCore.ts b/frontend/lib/agentCore.ts new file mode 100644 index 0000000000..a2f533522e --- /dev/null +++ b/frontend/lib/agentCore.ts @@ -0,0 +1,33 @@ +import type { Agent, AgentCore } from "@/types/agentConfig"; +import { DEFAULT_AGENT_VERIFICATION_CONFIG } from "@/types/agentConfig"; + +/** Apply native Jiuwen limitations without changing supported resources. */ +export function compatibleCorePatch>( + current: Partial | null, + patch: T +): T & Partial { + const core: AgentCore = + patch.agent_core ?? current?.agent_core ?? "smolagents"; + if (core !== "jiuwen") return patch; + const verification = + patch.verification_config ?? current?.verification_config; + return { + ...patch, + enable_context_manager: false, + provide_run_summary: false, + enable_protocol_repair_retry: false, + verification_config: { + ...DEFAULT_AGENT_VERIFICATION_CONFIG, + ...verification, + enabled: false, + guardrail_config: { + ...DEFAULT_AGENT_VERIFICATION_CONFIG.guardrail_config!, + ...verification?.guardrail_config, + enabled: false, + }, + }, + sub_agent_id_list: [], + sub_agent_relations: [], + external_sub_agent_id_list: [], + }; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 11a6761be5..83154ae747 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -5776,5 +5776,8 @@ "modelParams.token.maxOutputInfo": "Maximum output length per request", "modelParams.token.outputReserve": "Reserved output tokens", "modelParams.token.outputReserveHint": "Recommended: 2,000–10,000", - "modelParams.token.outputReserveInfo": "Output space reserved within the context window" + "modelParams.token.outputReserveInfo": "Output space reserved within the context window", + "agent.core.label": "Agent Core", + "agent.core.contextManager": "Automatic context compaction", + "agent.core.jiuwenHint": "Jiuwen does not yet support automatic context compaction, run summaries, protocol repair, verification, guardrails or collaborative agents. Switching disables these options and clears collaboration bindings while keeping models, prompts, tools and skills. Configure them again manually after switching back to smolagents." } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9b709dbfbe..a65043f00a 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -5790,5 +5790,8 @@ "modelParams.token.maxOutputInfo": "单次请求允许的最大输出长度", "modelParams.token.outputReserve": "输出预留Token数", "modelParams.token.outputReserveHint": "建议:2000-10000", - "modelParams.token.outputReserveInfo": "从上下文窗口中预留的输出空间" + "modelParams.token.outputReserveInfo": "从上下文窗口中预留的输出空间", + "agent.core.label": "Agent Core", + "agent.core.contextManager": "上下文自动压缩", + "agent.core.jiuwenHint": "Jiuwen 暂不支持上下文自动压缩、运行总结、协议修复、自校验、护栏及协作智能体。切换时会关闭这些选项并清空协作绑定,保留模型、提示词、工具和 Skills;切回 smolagents 后需手动重新配置。" } diff --git a/frontend/services/agentConfigService.ts b/frontend/services/agentConfigService.ts index 11d8a225a1..e1a46405a0 100644 --- a/frontend/services/agentConfigService.ts +++ b/frontend/services/agentConfigService.ts @@ -556,6 +556,7 @@ export const loadLastToolConfig = async (toolId: number) => { * @returns update result */ export interface UpdateAgentInfoPayload { + agent_core?: Agent["agent_core"]; agent_id?: number; name?: string; display_name?: string; @@ -1064,6 +1065,8 @@ export const searchAgentInfo = async ( // convert backend data to frontend format const formattedAgent = { id: data.agent_id, + agent_core: data.agent_core ?? "smolagents", + enable_context_manager: data.enable_context_manager ?? true, name: data.name, display_name: data.display_name, description: data.description, diff --git a/frontend/stores/agentStore.ts b/frontend/stores/agentStore.ts index d347e515cd..79f8181ac3 100644 --- a/frontend/stores/agentStore.ts +++ b/frontend/stores/agentStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { safeStringify } from "@/lib/utils"; +import { compatibleCorePatch } from "@/lib/agentCore"; import { searchToolConfig, updateAgentInfo, @@ -16,6 +17,7 @@ import { export type AgentDraft = Pick< Agent, | "name" + | "agent_core" | "display_name" | "description" | "author" @@ -110,6 +112,7 @@ interface AgentStoreState { } const toDraft = (agent: Agent): AgentDraft => ({ + agent_core: agent.agent_core ?? "smolagents", name: agent.name, display_name: agent.display_name || "", description: agent.description || "", @@ -274,6 +277,7 @@ const applyQueuedPatches = ( const toAgentPayload = (agentId: number, patch: AgentDraftPatch) => ({ agent_id: agentId, + ...(patch.agent_core !== undefined ? { agent_core: patch.agent_core } : {}), ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.display_name !== undefined ? { display_name: patch.display_name } @@ -576,7 +580,9 @@ export const useAgentStore = create((set) => { const task: AgentSaveTask = { agentId, - patch: cloneDraft(patch), + patch: cloneDraft( + compatibleCorePatch(useAgentStore.getState().editedAgent, patch) + ), ...options, }; saveQueue = [...saveQueue, task]; diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts index 2f6214ab88..f64234ef40 100644 --- a/frontend/types/agentConfig.ts +++ b/frontend/types/agentConfig.ts @@ -11,6 +11,8 @@ export type AgentConfigUpdate = Partial< Pick< Agent, | "name" + | "agent_core" + | "enable_context_manager" | "display_name" | "author" | "model" @@ -148,6 +150,7 @@ export interface ModelParamsOverrideEntry { } export interface Agent { + agent_core?: AgentCore; id: string; name: string; display_name?: string; @@ -217,6 +220,8 @@ export interface Agent { repository_info?: MyAgentRepositoryInfoItem[]; } +export type AgentCore = "smolagents" | "jiuwen"; + export interface Tool { id: string; name: string; From 5812d757ef2968c423fb508717806e1af0a3d7be Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Tue, 13 Oct 2026 17:45:08 +0800 Subject: [PATCH 05/26] fix(agent-core): disable Jiuwen planning in configuration debug --- .../[agentId]/components/debug/agent-debug.tsx | 17 +++++++++++++---- frontend/public/locales/en/common.json | 2 +- frontend/public/locales/zh/common.json | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/app/[locale]/agents/[agentId]/components/debug/agent-debug.tsx b/frontend/app/[locale]/agents/[agentId]/components/debug/agent-debug.tsx index 57b40d6e94..7dbeef009c 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/debug/agent-debug.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/debug/agent-debug.tsx @@ -176,6 +176,8 @@ const AgentDebugPanel: FC = ({ const agentId = useAgentStore((state) => state.agentId); const editedAgent = useAgentStore((state) => state.editedAgent); const [chatMode, setChatMode] = useState("execution"); + const effectiveChatMode = + editedAgent?.agent_core === "jiuwen" ? "execution" : chatMode; const [clearEpoch, setClearEpoch] = useState(0); const [compareClearEpoch, setCompareClearEpoch] = useState(0); const debugAgent = useMemo( @@ -204,10 +206,17 @@ const AgentDebugPanel: FC = ({ = ({
@@ -281,7 +290,7 @@ const AgentDebugPanel: FC = ({
diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 83154ae747..61be6e42fd 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -5779,5 +5779,5 @@ "modelParams.token.outputReserveInfo": "Output space reserved within the context window", "agent.core.label": "Agent Core", "agent.core.contextManager": "Automatic context compaction", - "agent.core.jiuwenHint": "Jiuwen does not yet support automatic context compaction, run summaries, protocol repair, verification, guardrails or collaborative agents. Switching disables these options and clears collaboration bindings while keeping models, prompts, tools and skills. Configure them again manually after switching back to smolagents." + "agent.core.jiuwenHint": "Jiuwen does not yet support planning, automatic context compaction, run summaries, protocol repair, verification, guardrails or collaborative agents. Switching disables these options and clears collaboration bindings while keeping models, prompts, tools and skills. Configure them again manually after switching back to smolagents." } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index a65043f00a..952854169e 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -5793,5 +5793,5 @@ "modelParams.token.outputReserveInfo": "从上下文窗口中预留的输出空间", "agent.core.label": "Agent Core", "agent.core.contextManager": "上下文自动压缩", - "agent.core.jiuwenHint": "Jiuwen 暂不支持上下文自动压缩、运行总结、协议修复、自校验、护栏及协作智能体。切换时会关闭这些选项并清空协作绑定,保留模型、提示词、工具和 Skills;切回 smolagents 后需手动重新配置。" + "agent.core.jiuwenHint": "Jiuwen 暂不支持规划、上下文自动压缩、运行总结、协议修复、自校验、护栏及协作智能体。切换时会关闭这些选项并清空协作绑定,保留模型、提示词、工具和 Skills;切回 smolagents 后需手动重新配置。" } From 1e9c3882c67685d47463466359578c75208c9bba Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Tue, 13 Oct 2026 19:22:11 +0800 Subject: [PATCH 06/26] fix(agent-core): replay Jiuwen conversation history as native messages --- .../core/agents/core_adapters/jiuwen.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/core_adapters/jiuwen.py index 47c4b7451f..7c07b3be21 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen.py +++ b/sdk/nexent/core/agents/core_adapters/jiuwen.py @@ -22,6 +22,24 @@ logger = logging.getLogger(__name__) +def _prepend_context_messages(messages, prefix): + """Replay authorized context without mixing dictionaries and native messages.""" + from openjiuwen.core.foundation.llm.schema.message import AssistantMessage, BaseMessage, ToolMessage, UserMessage + + def native(message): + if not isinstance(message, dict): + return message + message_type = {"assistant": AssistantMessage, "tool": ToolMessage}.get(message["role"], BaseMessage) + return message_type.model_validate(dict(message)) + + current = [UserMessage(content=messages)] if isinstance(messages, str) else [native(item) for item in messages] + # Keep the harness system prompt first, then previous turns, then this run. + boundary = 0 + while boundary < len(current) and current[boundary].role in {"system", "developer"}: + boundary += 1 + return [*current[:boundary], *(native(item) for item in prefix), *current[boundary:]] + + def _tool_schema(inputs): """Convert existing tool parameter declarations into JSON Schema.""" if isinstance(inputs, str): @@ -260,7 +278,7 @@ def acquire(messages, tools): class RunModel(Model): async def invoke(model_self, messages, **kwargs): start_model_call() - messages = [*prefix, *messages] + messages = _prepend_context_messages(messages, prefix) permit = acquire(messages, kwargs.get("tools")) try: return await super().invoke(messages, **kwargs) @@ -270,7 +288,7 @@ async def invoke(model_self, messages, **kwargs): async def stream(model_self, messages, **kwargs): start_model_call() - messages = [*prefix, *messages] + messages = _prepend_context_messages(messages, prefix) permit = acquire(messages, kwargs.get("tools")) try: async for chunk in super().stream(messages, **kwargs): From d4d472dbb22e484d8776a517abd2acd3894e21d1 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Tue, 13 Oct 2026 19:57:35 +0800 Subject: [PATCH 07/26] fix(agent-core): isolate current Jiuwen tasks and upgrade native core --- backend/agents/create_agent_info.py | 7 +++++++ backend/pyproject.toml | 2 +- sdk/nexent/core/agents/core_adapters/jiuwen.py | 15 ++++++++++++++- sdk/pyproject.toml | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index d6d084cd79..8cd34e9399 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -2717,6 +2717,13 @@ async def create_agent_run_info( create_config_kwargs["runtime_sub_agent_mounts"] = runtime_sub_agent_mounts agent_config = await create_agent_config(**create_config_kwargs, tool_params=tool_params) + if getattr(agent_config, "agent_core", "smolagents") == "jiuwen": + # Historical attachments remain in authorized history and the workspace; + # only this turn's uploads may frame the current request as a file task. + final_query = await join_minio_file_description_to_query( + minio_files=minio_files, query=query, history=None, language=language, + ) + # v2.6.0: Apply per-agent model params override to model_config_list. # model_params_override is a {model_id_str: {temperature, top_p, extra_params}} dict # stored on ag_tenant_agent_t. We match each override entry to a ModelConfig diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8c5de696aa..e7acedf023 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "scikit-learn>=1.0.0", "numpy>=1.24.0", "defusedxml>=0.7.1", - "openjiuwen==0.1.18", + "openjiuwen==0.1.19", "pydantic-settings>=2.0.0", "python-docx>=1.1.0", "xlrd>=2.0.1", diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/core_adapters/jiuwen.py index 7c07b3be21..c025409fc9 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen.py +++ b/sdk/nexent/core/agents/core_adapters/jiuwen.py @@ -145,6 +145,7 @@ def prepare(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard from openjiuwen.core.single_agent import AgentCard from openjiuwen.harness.factory import create_deep_agent + from openjiuwen.harness.rails.model_anomaly_detection_rail import ModelAnomalyDetectionRail from ..run_agent import _get_default_agent_thread_manager @@ -178,6 +179,11 @@ def prepare(self): self.query = self.resources._prepare_file_workspace(info.query) prompt = self._compile_context() rails = self._skill_rails() + # Retry only the native model call, never a completed business tool or run. + rails.append(ModelAnomalyDetectionRail( + max_retries=0, transient_max_retries=1, transient_base_delay_seconds=0.5, + tool_loop_compact={"enabled": False}, + )) model = self._create_model() self.native_agent = create_deep_agent( model, card=AgentCard(id=self.owner_id, name=info.agent_config.name, @@ -241,7 +247,14 @@ def _compile_context(self): {"role": history.role, "content": history.content} for history in _get_authorized_history(self.run_info) or () ) - return "\n\n".join(text for text in instructions if text) or config.description + prompt = "\n\n".join(text for text in instructions if text) or config.description + current_task_guidance = ( + "Process only the latest user request in this run. Earlier conversation messages, attachments and " + "results are historical reference, not pending tasks. Do not repeat earlier requests or tool calls " + "unless the latest user request explicitly asks you to. Reuse prior results when answering follow-up " + "questions; the presence of an earlier attachment does not require analyzing it again." + ) + return prompt + "\n\n" + current_task_guidance def _create_model(self): from openjiuwen.core.foundation.llm import Model diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index a99eec4936..7dc488c9a4 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -75,7 +75,7 @@ override-dependencies = [ [project.optional-dependencies] jiuwen = [ - "openjiuwen==0.1.18", + "openjiuwen==0.1.19", ] quality = [ "ruff>=0.9.0", From 7fd889cd13e1e318b9364b4873cc47d787dd3f4c Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Mon, 19 Oct 2026 10:30:34 +0800 Subject: [PATCH 08/26] feat(agent-core): integrate Jiuwen with shared Nexent runtime Route Jiuwen model requests through the shared ModelService with trusted outlet snapshots, native tool calling and a controlled text protocol. Bridge authorized tools, MCP, Skills, subagents and A2A; preserve saved Core configuration and reuse Nexent context management and database history without replaying old tasks. Normalize public step observations and run results into existing Nexent chat events, including shared capacity metadata when context management is disabled. Add the v2.7.2 outlet migration, integration documentation and regression assets. Validation: previously completed Python/frontend UT and targeted formal checks; capacity regression rerun: 11 passed. Eight real-model business turns covered image analysis, follow-ups and Core switching. Formal real UI case remains failed due to AIDP 502; global asset gate remains blocked by F-111-RULE-02. --- .../references/components/runtime-output.md | 11 + backend/agents/create_agent_info.py | 198 ++- backend/consts/exceptions.py | 16 + backend/consts/model.py | 3 + backend/consts/model_profile.py | 53 + backend/database/conversation_db.py | 56 +- backend/database/db_models.py | 6 + backend/database/model_management_db.py | 30 +- backend/database/model_runtime_profile_db.py | 31 + backend/management/services/agent/run.py | 65 +- backend/scripts/migrate_runtime_outlets.py | 36 + .../conversation_management_service.py | 23 +- backend/services/model_health_service.py | 9 +- backend/services/model_management_service.py | 7 +- .../model_outlet_migration_service.py | 194 +++ backend/services/model_profile_service.py | 143 ++ .../services/model_runtime_options_service.py | 88 + .../model_runtime_provisioning_service.py | 108 ++ .../services/model_runtime_refresh_service.py | 62 + .../model_runtime_snapshot_service.py | 44 + backend/services/user_management_service.py | 8 +- backend/utils/agent_stream_utils.py | 11 + backend/utils/runtime_output.py | 37 + .../v2.7.2_1015_runtime_outlet_snapshot.sql | 11 + docs/agent-core-adapters.md | 108 ++ .../components/agent-core-selector.tsx | 2 +- .../components/collaborative-agent.tsx | 4 +- .../conversation-thread-list-adapter.tsx | 20 +- .../adapter/remote-chat-model-adapter.ts | 50 +- .../[locale]/newchat/assistant-ui/thread.tsx | 2 + frontend/app/[locale]/newchat/ui/subagent.tsx | 29 +- .../app/[locale]/newchat/ui/token-usage.tsx | 93 +- .../app/[locale]/newchat/ui/tool-fallback.tsx | 15 +- frontend/lib/agentCore.ts | 32 +- frontend/lib/nexentOutputAdapter.ts | 436 +++++ frontend/public/locales/en/common.json | 13 +- frontend/public/locales/zh/common.json | 13 +- frontend/tests/component/TokenUsage.test.tsx | 77 + .../tests/component/ToolErrorStatus.test.tsx | 28 + frontend/tests/component/agentCore.test.tsx | 131 ++ .../component/contextCapacityOutput.test.tsx | 60 + .../tests/component/runtimeOutput.test.tsx | 398 +++++ .../component/subagentRuntimeStatus.test.tsx | 30 + .../workbench/UnifiedOutputStream.test.ts | 261 +++ frontend/types/nexentOutput.ts | 22 + .../core/agent_runtime_support/__init__.py | 5 + .../core/agent_runtime_support/contracts.py | 56 + .../core/agent_runtime_support/output.py | 47 + sdk/nexent/core/agents/agent_model.py | 12 + .../agents/context/history_compression.py | 6 +- .../core/agents/context/history_facts.py | 33 + sdk/nexent/core/agents/context/manager.py | 34 +- sdk/nexent/core/agents/context/rendering.py | 13 +- sdk/nexent/core/agents/core_adapters/base.py | 4 +- .../core/agents/core_adapters/children.py | 42 + .../core/agents/core_adapters/events.py | 149 +- .../core/agents/core_adapters/jiuwen.py | 394 +++-- .../agents/core_adapters/jiuwen_context.py | 192 +++ .../agents/core_adapters/jiuwen_messages.py | 79 + .../core/agents/core_adapters/jiuwen_model.py | 276 ++++ .../agents/core_adapters/jiuwen_protocol.py | 58 + .../agents/core_adapters/jiuwen_resources.py | 71 + sdk/nexent/core/agents/core_adapters/mcp.py | 10 +- .../core/agents/core_adapters/skills.py | 9 +- .../core/agents/core_adapters/smolagents.py | 12 +- sdk/nexent/core/agents/nexent_agent.py | 9 + sdk/nexent/core/agents/run_agent.py | 23 +- sdk/nexent/core/model_service/__init__.py | 1 + .../core/model_service/adapters/__init__.py | 1 + .../model_service/adapters/openai_chat.py | 305 ++++ .../model_service/adapters/openai_stream.py | 235 +++ .../model_service/compatibility/__init__.py | 1 + .../compatibility/invocation_options.py | 95 ++ .../compatibility/legacy_chat_model.py | 409 +++++ .../compatibility/legacy_config.py | 107 ++ .../compatibility/legacy_reasoning.py | 269 ++++ .../model_service/compatibility/long_text.py | 202 +++ sdk/nexent/core/model_service/concurrency.py | 119 ++ sdk/nexent/core/model_service/contracts.py | 158 ++ sdk/nexent/core/model_service/errors.py | 114 ++ .../core/model_service/http_contracts.py | 125 ++ sdk/nexent/core/model_service/http_sender.py | 296 ++++ .../model_service/parameter_transforms.py | 32 + .../core/model_service/profiles/LICENSE | 21 + .../core/model_service/profiles/README.md | 60 + .../core/model_service/profiles/__init__.py | 1 + .../profiles/_models_dev/__init__.py | 1 + .../profiles/_models_dev/inheritance.py | 136 ++ .../profiles/_models_dev/mapping.py | 191 +++ .../profiles/_models_dev/reader.py | 155 ++ .../profiles/_models_dev/validation.py | 480 ++++++ .../model_service/profiles/composition.py | 368 +++++ .../core/model_service/profiles/contracts.py | 236 +++ .../profiles/instruction_mapping.py | 44 + .../core/model_service/profiles/manifest.toml | 229 +++ .../models/alibaba/qwen3-coder-next.toml | 27 + .../profiles/models/alibaba/qwen3.5-27b.toml | 29 + .../models/alibaba/qwen3.5-35b-a3b.toml | 23 + .../models/alibaba/qwen3.5-397b-a17b.toml | 29 + .../models/alibaba/qwen3.5-flash.toml | 22 + .../profiles/models/alibaba/qwen3.5-plus.toml | 19 + .../models/alibaba/qwen3.8-2.4t-a95b.toml | 33 + .../profiles/models/alibaba/qwen3.8-27b.toml | 236 +++ .../models/alibaba/qwen3.8-flash.toml | 19 + .../profiles/models/alibaba/qwen3.8-max.toml | 266 ++++ .../deepseek/deepseek-v4-flash-0731.toml | 105 ++ .../models/deepseek/deepseek-v4-flash.toml | 183 +++ .../models/deepseek/deepseek-v4-pro-0813.toml | 106 ++ .../models/deepseek/deepseek-v4-pro.toml | 217 +++ .../models/deepseek/deepseek-v4.1-flash.toml | 311 ++++ .../profiles/models/minimax/MiniMax-M2.7.toml | 46 + .../profiles/models/minimax/MiniMax-M3.toml | 67 + .../profiles/models/zhipuai/glm-5.1.toml | 53 + .../profiles/models/zhipuai/glm-5.2.toml | 167 ++ .../models/zhipuai/glm-5.3-flash.toml | 70 + .../profiles/models/zhipuai/glm-5.3.toml | 158 ++ .../profiles/outlet_capabilities.py | 140 ++ .../profiles/outlet_parameters.py | 100 ++ .../profiles/outlet_resolution.py | 141 ++ .../model_service/profiles/outlet_rules.py | 130 ++ .../model_service/profiles/outlet_storage.py | 215 +++ .../profiles/parameter_bindings.py | 53 + .../model_service/profiles/parameter_rules.py | 240 +++ .../profiles/parameter_schema.py | 212 +++ .../profiles/protocol_parameters.py | 18 + .../models/MiniMax/MiniMax-M2.7.toml | 25 + .../alibaba-cn/models/deepseek-v4-flash.toml | 16 + .../alibaba-cn/models/deepseek-v4-pro.toml | 16 + .../models/deepseek-v4.1-flash.toml | 27 + .../providers/alibaba-cn/models/glm-5.1.toml | 45 + .../providers/alibaba-cn/models/glm-5.2.toml | 22 + .../providers/alibaba-cn/models/glm-5.3.toml | 25 + .../alibaba-cn/models/qwen3.5-397b-a17b.toml | 42 + .../alibaba-cn/models/qwen3.5-flash.toml | 53 + .../alibaba-cn/models/qwen3.5-plus.toml | 53 + .../alibaba-cn/models/qwen3.8-flash.toml | 26 + .../alibaba-cn/models/qwen3.8-max.toml | 27 + .../providers/alibaba-cn/provider.toml | 20 + .../models/deepseek-v4-flash.toml | 23 + .../models/deepseek-v4-pro.toml | 22 + .../models/glm-5.3-flash.toml | 17 + .../models/glm-5.3.toml | 30 + .../models/minimax-m3.toml | 21 + .../volcengine-coding-plan/provider.toml | 42 + .../models/deepseek-v4-flash-ga-260731.toml | 21 + .../models/deepseek-v4-pro-ga-260813.toml | 21 + .../volcengine/models/glm-5-2-260617.toml | 21 + .../models/glm-5-3-flash-260828.toml | 18 + .../providers/volcengine/provider.toml | 23 + .../profiles/request_parameters.py | 109 ++ .../profiles/resolved_profile.py | 200 +++ .../core/model_service/profiles/resolver.py | 288 ++++ .../profiles/rules/manifest.toml | 13 + .../rules/mappings/models_dev/v1.toml | 1417 +++++++++++++++++ .../rules/models/alibaba/qwen3.5-plus.toml | 12 + .../rules/providers/alibaba-cn/provider.toml | 8 + .../providers/alibaba-cn/qwen3.5-plus.toml | 117 ++ .../model_service/profiles/supplements.py | 246 +++ sdk/nexent/core/model_service/registry.py | 110 ++ .../core/model_service/resource_conversion.py | 102 ++ sdk/nexent/core/model_service/service.py | 547 +++++++ sdk/nexent/core/model_service/sse.py | 138 ++ sdk/nexent/core/models/capacity_budget.py | 31 + sdk/nexent/core/tools/parallel_executor.py | 20 +- sdk/nexent/core/utils/observer.py | 17 +- sdk/nexent/monitor/monitoring.py | 13 +- sdk/pyproject.toml | 2 + test-e2e/cases/AGENT-CORE-UI-D4-001/case.yaml | 84 + .../cases/AGENT-CORE-UI-D4-001/execution.yaml | 15 + .../cases/AGENT-CORE-UI-D4-001/test.spec.ts | 128 ++ .../cases/JIUWEN-CONTEXT-D3-001/case.yaml | 71 + .../JIUWEN-CONTEXT-D3-001/execution.yaml | 15 + .../JIUWEN-CONTEXT-D3-001/test_context.py | 45 + .../cases/JIUWEN-CONTEXT-D4-001/case.yaml | 67 + .../JIUWEN-CONTEXT-D4-001/execution.yaml | 15 + .../cases/JIUWEN-CONTEXT-D4-001/test.spec.ts | 107 ++ .../cases/JIUWEN-CONTEXT-D5-001/case.yaml | 79 + .../JIUWEN-CONTEXT-D5-001/execution.yaml | 15 + .../JIUWEN-CONTEXT-D5-001/test_context.py | 35 + test-e2e/cases/JIUWEN-IMAGE-D3-001/case.yaml | 68 + .../JIUWEN-IMAGE-D3-001/deployed_image.py | 111 ++ .../cases/JIUWEN-IMAGE-D3-001/execution.yaml | 15 + .../cases/JIUWEN-IMAGE-D3-001/test_image.py | 70 + .../JIUWEN-MODEL-BRIDGE-D3-001/case.yaml | 70 + .../JIUWEN-MODEL-BRIDGE-D3-001/execution.yaml | 16 + .../test_runtime.py | 43 + .../JIUWEN-MODEL-BRIDGE-D5-001/case.yaml | 79 + .../JIUWEN-MODEL-BRIDGE-D5-001/execution.yaml | 20 + .../test_reliability.py | 69 + test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml | 74 + .../cases/JIUWEN-OUTPUT-D3-001/execution.yaml | 15 + .../cases/JIUWEN-OUTPUT-D3-001/test_output.py | 70 + test-e2e/cases/JIUWEN-OUTPUT-D4-001/case.yaml | 72 + .../cases/JIUWEN-OUTPUT-D4-001/execution.yaml | 15 + .../cases/JIUWEN-OUTPUT-D4-001/test.spec.ts | 169 ++ test-e2e/cases/JIUWEN-OUTPUT-D5-001/case.yaml | 72 + .../cases/JIUWEN-OUTPUT-D5-001/execution.yaml | 15 + .../cases/JIUWEN-OUTPUT-D5-001/test_output.py | 48 + .../cases/JIUWEN-REAL-UI-D4-001/case.yaml | 100 ++ .../JIUWEN-REAL-UI-D4-001/execution.yaml | 16 + .../cases/JIUWEN-REAL-UI-D4-001/fixture.py | 141 ++ .../cases/JIUWEN-REAL-UI-D4-001/test.spec.ts | 222 +++ .../cases/JIUWEN-RECOVERY-D5-001/case.yaml | 75 + .../JIUWEN-RECOVERY-D5-001/execution.yaml | 16 + .../JIUWEN-RECOVERY-D5-001/test_recovery.py | 192 +++ .../cases/JIUWEN-RELEASE-D3-001/case.yaml | 72 + .../JIUWEN-RELEASE-D3-001/deployed_smoke.py | 476 ++++++ .../JIUWEN-RELEASE-D3-001/execution.yaml | 17 + .../JIUWEN-RELEASE-D3-001/test_release.py | 64 + .../cases/JIUWEN-RESOURCES-D3-001/case.yaml | 68 + .../JIUWEN-RESOURCES-D3-001/execution.yaml | 15 + .../JIUWEN-RESOURCES-D3-001/test_resources.py | 42 + .../cases/JIUWEN-RESOURCES-D3-002/case.yaml | 64 + .../JIUWEN-RESOURCES-D3-002/execution.yaml | 15 + .../test_collaboration.py | 56 + .../cases/JIUWEN-RESOURCES-D5-001/case.yaml | 68 + .../JIUWEN-RESOURCES-D5-001/execution.yaml | 15 + .../JIUWEN-RESOURCES-D5-001/test_resources.py | 30 + .../cases/JIUWEN-RESOURCES-D5-002/case.yaml | 84 + .../JIUWEN-RESOURCES-D5-002/execution.yaml | 15 + .../test_profile_persistence.py | 182 +++ .../cases/JIUWEN-RESOURCES-D5-003/case.yaml | 70 + .../JIUWEN-RESOURCES-D5-003/execution.yaml | 15 + .../test_collaboration.py | 43 + .../bugs/JIUWEN-CAPACITY-20261019.yaml | 48 + .../bugs/JIUWEN-CURRENT-TASK-20261013.yaml | 25 + .../changes/bugs/JIUWEN-HISTORY-20261013.yaml | 33 + .../changes/bugs/JIUWEN-IMAGE-20261016.yaml | 50 + .../requirements/AGENT-CORE-UI-20261013.yaml | 33 + .../requirements/JIUWEN-M3-20261015.yaml | 72 + .../requirements/JIUWEN-M4-20261016.yaml | 49 + .../requirements/JIUWEN-M5-20261016.yaml | 48 + .../requirements/JIUWEN-M6-20261016.yaml | 53 + .../JIUWEN-MODEL-BRIDGE-20261015.yaml | 51 + .../JIUWEN-UNIFIED-OUTPUT-20261016.yaml | 34 + .../test-fixes/JIUWEN-REAL-UI-20261019.yaml | 24 + .../features/AGENT-CORE-CONFIG/feature.yaml | 26 + test-e2e/features/JIUWEN-CONTEXT/feature.yaml | 29 + .../features/JIUWEN-MODEL-BRIDGE/feature.yaml | 31 + test-e2e/features/JIUWEN-OUTPUT/feature.yaml | 37 + .../features/JIUWEN-RECOVERY/feature.yaml | 25 + test-e2e/features/JIUWEN-RELEASE/feature.yaml | 21 + .../features/JIUWEN-RESOURCES/feature.yaml | 31 + .../infra/automation/jiuwen_bridge_fixture.py | 146 ++ .../infra/automation/jiuwen_bridge_runtime.py | 97 ++ .../jiuwen_collaboration_runtime.py | 216 +++ .../automation/jiuwen_context_runtime.py | 354 ++++ .../infra/automation/jiuwen_output_runtime.py | 50 + .../automation/jiuwen_resource_provider.py | 125 ++ .../automation/jiuwen_resource_runtime.py | 191 +++ test/automation/d1/test_agent_core_factory.py | 134 ++ .../d2/test_agent_core_factory_contract.py | 77 + test/automation/d3/jiuwen_http_fixture.py | 103 ++ .../d3/test_agent_core_factory_native.py | 167 ++ .../d5/test_agent_core_factory_reliability.py | 122 ++ .../adapters/test_jiuwen_sdk_adapter.py | 9 +- .../agents/test_child_model_binding.py | 37 + test/backend/agents/test_jiuwen_cm_policy.py | 17 + .../agents/test_jiuwen_current_task.py | 47 + .../test_conversation_append_order.py | 38 + test/backend/database/test_conversation_db.py | 4 +- .../test_conversation_history_stopped.py | 23 +- .../services/test_agent_core_creation.py | 50 + test/backend/services/test_agent_service.py | 17 + .../test_conversation_management_service.py | 65 +- .../test_conversation_runtime_boundary.py | 26 + .../services/test_jiuwen_history_boundary.py | 23 + ...test_model_runtime_provisioning_service.py | 180 +++ .../test_model_runtime_snapshot_service.py | 34 + test/backend/utils/test_runtime_output.py | 42 + test/cases/d1/agent-core-factory.yaml | 132 ++ test/cases/d2/agent-core-factory.yaml | 49 + test/cases/d3/agent-core-factory.yaml | 70 + test/cases/d5/agent-core-factory.yaml | 58 + .../requirements/agent-core-factory.yaml | 25 + test/features/agent-core-factory.yaml | 33 + test/manifests/agent-core-factory.yaml | 68 + .../core/agents/test_core_child_dispatch.py | 105 ++ .../agents/test_jiuwen_capacity_output.py | 94 ++ test/sdk/core/agents/test_jiuwen_context.py | 124 ++ test/sdk/core/agents/test_jiuwen_history.py | 162 ++ .../core/agents/test_jiuwen_mcp_transport.py | 46 + .../core/agents/test_jiuwen_model_service.py | 464 ++++++ test/sdk/core/agents/test_jiuwen_output.py | 128 ++ test/sdk/core/agents/test_jiuwen_protocol.py | 46 + test/sdk/core/agents/test_jiuwen_resources.py | 121 ++ test/sdk/core/model_service/conftest.py | 70 + .../core/model_service/test_http_contracts.py | 133 ++ .../core/model_service/test_http_sender.py | 395 +++++ .../test_legacy_configuration_export.py | 46 + .../test_legacy_invocation_options.py | 121 ++ .../test_legacy_reasoning_export.py | 54 + .../core/model_service/test_model_service.py | 425 +++++ .../model_service/test_openai_chat_adapter.py | 442 +++++ .../core/model_service/test_openai_stream.py | 365 +++++ .../core/model_service/test_outlet_storage.py | 433 +++++ .../test_protocol_diagnostics.py | 59 + .../model_service/test_resource_conversion.py | 127 ++ .../models/test_openai_llm_timeout_compat.py | 58 + .../monitor/test_monitoring_token_totals.py | 26 + 300 files changed, 27583 insertions(+), 450 deletions(-) create mode 100644 .agents/skills/nexent-frontend/references/components/runtime-output.md create mode 100644 backend/consts/model_profile.py create mode 100644 backend/database/model_runtime_profile_db.py create mode 100644 backend/scripts/migrate_runtime_outlets.py create mode 100644 backend/services/model_outlet_migration_service.py create mode 100644 backend/services/model_profile_service.py create mode 100644 backend/services/model_runtime_options_service.py create mode 100644 backend/services/model_runtime_provisioning_service.py create mode 100644 backend/services/model_runtime_refresh_service.py create mode 100644 backend/services/model_runtime_snapshot_service.py create mode 100644 backend/utils/runtime_output.py create mode 100644 deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql create mode 100644 docs/agent-core-adapters.md create mode 100644 frontend/lib/nexentOutputAdapter.ts create mode 100644 frontend/tests/component/TokenUsage.test.tsx create mode 100644 frontend/tests/component/ToolErrorStatus.test.tsx create mode 100644 frontend/tests/component/agentCore.test.tsx create mode 100644 frontend/tests/component/contextCapacityOutput.test.tsx create mode 100644 frontend/tests/component/runtimeOutput.test.tsx create mode 100644 frontend/tests/component/subagentRuntimeStatus.test.tsx create mode 100644 frontend/tests/workbench/UnifiedOutputStream.test.ts create mode 100644 frontend/types/nexentOutput.ts create mode 100644 sdk/nexent/core/agent_runtime_support/__init__.py create mode 100644 sdk/nexent/core/agent_runtime_support/contracts.py create mode 100644 sdk/nexent/core/agent_runtime_support/output.py create mode 100644 sdk/nexent/core/agents/context/history_facts.py create mode 100644 sdk/nexent/core/agents/core_adapters/children.py create mode 100644 sdk/nexent/core/agents/core_adapters/jiuwen_context.py create mode 100644 sdk/nexent/core/agents/core_adapters/jiuwen_messages.py create mode 100644 sdk/nexent/core/agents/core_adapters/jiuwen_model.py create mode 100644 sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py create mode 100644 sdk/nexent/core/agents/core_adapters/jiuwen_resources.py create mode 100644 sdk/nexent/core/model_service/__init__.py create mode 100644 sdk/nexent/core/model_service/adapters/__init__.py create mode 100644 sdk/nexent/core/model_service/adapters/openai_chat.py create mode 100644 sdk/nexent/core/model_service/adapters/openai_stream.py create mode 100644 sdk/nexent/core/model_service/compatibility/__init__.py create mode 100644 sdk/nexent/core/model_service/compatibility/invocation_options.py create mode 100644 sdk/nexent/core/model_service/compatibility/legacy_chat_model.py create mode 100644 sdk/nexent/core/model_service/compatibility/legacy_config.py create mode 100644 sdk/nexent/core/model_service/compatibility/legacy_reasoning.py create mode 100644 sdk/nexent/core/model_service/compatibility/long_text.py create mode 100644 sdk/nexent/core/model_service/concurrency.py create mode 100644 sdk/nexent/core/model_service/contracts.py create mode 100644 sdk/nexent/core/model_service/errors.py create mode 100644 sdk/nexent/core/model_service/http_contracts.py create mode 100644 sdk/nexent/core/model_service/http_sender.py create mode 100644 sdk/nexent/core/model_service/parameter_transforms.py create mode 100644 sdk/nexent/core/model_service/profiles/LICENSE create mode 100644 sdk/nexent/core/model_service/profiles/README.md create mode 100644 sdk/nexent/core/model_service/profiles/__init__.py create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/__init__.py create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/mapping.py create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/reader.py create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/validation.py create mode 100644 sdk/nexent/core/model_service/profiles/composition.py create mode 100644 sdk/nexent/core/model_service/profiles/contracts.py create mode 100644 sdk/nexent/core/model_service/profiles/instruction_mapping.py create mode 100644 sdk/nexent/core/model_service/profiles/manifest.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3-coder-next.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-27b.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-35b-a3b.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-397b-a17b.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-plus.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-2.4t-a95b.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-27b.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-max.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash-0731.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro-0813.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4.1-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M2.7.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M3.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.1.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.2.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3.toml create mode 100644 sdk/nexent/core/model_service/profiles/outlet_capabilities.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_parameters.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_resolution.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_rules.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_storage.py create mode 100644 sdk/nexent/core/model_service/profiles/parameter_bindings.py create mode 100644 sdk/nexent/core/model_service/profiles/parameter_rules.py create mode 100644 sdk/nexent/core/model_service/profiles/parameter_schema.py create mode 100644 sdk/nexent/core/model_service/profiles/protocol_parameters.py create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/MiniMax/MiniMax-M2.7.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-pro.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4.1-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.1.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.2.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.3.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.5-397b-a17b.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.5-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.5-plus.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.8-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.8-max.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/alibaba-cn/provider.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/deepseek-v4-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/deepseek-v4-pro.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3-flash.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/minimax-m3.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/provider.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-flash-ga-260731.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-pro-ga-260813.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-2-260617.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-3-flash-260828.toml create mode 100644 sdk/nexent/core/model_service/profiles/providers/volcengine/provider.toml create mode 100644 sdk/nexent/core/model_service/profiles/request_parameters.py create mode 100644 sdk/nexent/core/model_service/profiles/resolved_profile.py create mode 100644 sdk/nexent/core/model_service/profiles/resolver.py create mode 100644 sdk/nexent/core/model_service/profiles/rules/manifest.toml create mode 100644 sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml create mode 100644 sdk/nexent/core/model_service/profiles/rules/models/alibaba/qwen3.5-plus.toml create mode 100644 sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/provider.toml create mode 100644 sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/qwen3.5-plus.toml create mode 100644 sdk/nexent/core/model_service/profiles/supplements.py create mode 100644 sdk/nexent/core/model_service/registry.py create mode 100644 sdk/nexent/core/model_service/resource_conversion.py create mode 100644 sdk/nexent/core/model_service/service.py create mode 100644 sdk/nexent/core/model_service/sse.py create mode 100644 test-e2e/cases/AGENT-CORE-UI-D4-001/case.yaml create mode 100644 test-e2e/cases/AGENT-CORE-UI-D4-001/execution.yaml create mode 100644 test-e2e/cases/AGENT-CORE-UI-D4-001/test.spec.ts create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D3-001/test_context.py create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D4-001/test.spec.ts create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D5-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D5-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-CONTEXT-D5-001/test_context.py create mode 100644 test-e2e/cases/JIUWEN-IMAGE-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-IMAGE-D3-001/deployed_image.py create mode 100644 test-e2e/cases/JIUWEN-IMAGE-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-IMAGE-D3-001/test_image.py create mode 100644 test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py create mode 100644 test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/test_reliability.py create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D4-001/test.spec.ts create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D5-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D5-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-OUTPUT-D5-001/test_output.py create mode 100644 test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py create mode 100644 test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts create mode 100644 test-e2e/cases/JIUWEN-RECOVERY-D5-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RECOVERY-D5-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py create mode 100644 test-e2e/cases/JIUWEN-RELEASE-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py create mode 100644 test-e2e/cases/JIUWEN-RELEASE-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RELEASE-D3-001/test_release.py create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-002/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-002/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-002/test_collaboration.py create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-001/test_resources.py create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-002/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-002/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-002/test_profile_persistence.py create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-003/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-003/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D5-003/test_collaboration.py create mode 100644 test-e2e/changes/bugs/JIUWEN-CAPACITY-20261019.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-CURRENT-TASK-20261013.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-HISTORY-20261013.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-IMAGE-20261016.yaml create mode 100644 test-e2e/changes/requirements/AGENT-CORE-UI-20261013.yaml create mode 100644 test-e2e/changes/requirements/JIUWEN-M3-20261015.yaml create mode 100644 test-e2e/changes/requirements/JIUWEN-M4-20261016.yaml create mode 100644 test-e2e/changes/requirements/JIUWEN-M5-20261016.yaml create mode 100644 test-e2e/changes/requirements/JIUWEN-M6-20261016.yaml create mode 100644 test-e2e/changes/requirements/JIUWEN-MODEL-BRIDGE-20261015.yaml create mode 100644 test-e2e/changes/requirements/JIUWEN-UNIFIED-OUTPUT-20261016.yaml create mode 100644 test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml create mode 100644 test-e2e/features/AGENT-CORE-CONFIG/feature.yaml create mode 100644 test-e2e/features/JIUWEN-CONTEXT/feature.yaml create mode 100644 test-e2e/features/JIUWEN-MODEL-BRIDGE/feature.yaml create mode 100644 test-e2e/features/JIUWEN-OUTPUT/feature.yaml create mode 100644 test-e2e/features/JIUWEN-RECOVERY/feature.yaml create mode 100644 test-e2e/features/JIUWEN-RELEASE/feature.yaml create mode 100644 test-e2e/features/JIUWEN-RESOURCES/feature.yaml create mode 100644 test-e2e/infra/automation/jiuwen_bridge_fixture.py create mode 100644 test-e2e/infra/automation/jiuwen_bridge_runtime.py create mode 100644 test-e2e/infra/automation/jiuwen_collaboration_runtime.py create mode 100644 test-e2e/infra/automation/jiuwen_context_runtime.py create mode 100644 test-e2e/infra/automation/jiuwen_output_runtime.py create mode 100644 test-e2e/infra/automation/jiuwen_resource_provider.py create mode 100644 test-e2e/infra/automation/jiuwen_resource_runtime.py create mode 100644 test/automation/d1/test_agent_core_factory.py create mode 100644 test/automation/d2/test_agent_core_factory_contract.py create mode 100644 test/automation/d3/jiuwen_http_fixture.py create mode 100644 test/automation/d3/test_agent_core_factory_native.py create mode 100644 test/automation/d5/test_agent_core_factory_reliability.py create mode 100644 test/backend/agents/test_child_model_binding.py create mode 100644 test/backend/agents/test_jiuwen_cm_policy.py create mode 100644 test/backend/agents/test_jiuwen_current_task.py create mode 100644 test/backend/database/test_conversation_append_order.py create mode 100644 test/backend/services/test_agent_core_creation.py create mode 100644 test/backend/services/test_conversation_runtime_boundary.py create mode 100644 test/backend/services/test_jiuwen_history_boundary.py create mode 100644 test/backend/services/test_model_runtime_provisioning_service.py create mode 100644 test/backend/services/test_model_runtime_snapshot_service.py create mode 100644 test/backend/utils/test_runtime_output.py create mode 100644 test/cases/d1/agent-core-factory.yaml create mode 100644 test/cases/d2/agent-core-factory.yaml create mode 100644 test/cases/d3/agent-core-factory.yaml create mode 100644 test/cases/d5/agent-core-factory.yaml create mode 100644 test/changes/requirements/agent-core-factory.yaml create mode 100644 test/features/agent-core-factory.yaml create mode 100644 test/manifests/agent-core-factory.yaml create mode 100644 test/sdk/core/agents/test_core_child_dispatch.py create mode 100644 test/sdk/core/agents/test_jiuwen_capacity_output.py create mode 100644 test/sdk/core/agents/test_jiuwen_context.py create mode 100644 test/sdk/core/agents/test_jiuwen_history.py create mode 100644 test/sdk/core/agents/test_jiuwen_mcp_transport.py create mode 100644 test/sdk/core/agents/test_jiuwen_model_service.py create mode 100644 test/sdk/core/agents/test_jiuwen_output.py create mode 100644 test/sdk/core/agents/test_jiuwen_protocol.py create mode 100644 test/sdk/core/agents/test_jiuwen_resources.py create mode 100644 test/sdk/core/model_service/conftest.py create mode 100644 test/sdk/core/model_service/test_http_contracts.py create mode 100644 test/sdk/core/model_service/test_http_sender.py create mode 100644 test/sdk/core/model_service/test_legacy_configuration_export.py create mode 100644 test/sdk/core/model_service/test_legacy_invocation_options.py create mode 100644 test/sdk/core/model_service/test_legacy_reasoning_export.py create mode 100644 test/sdk/core/model_service/test_model_service.py create mode 100644 test/sdk/core/model_service/test_openai_chat_adapter.py create mode 100644 test/sdk/core/model_service/test_openai_stream.py create mode 100644 test/sdk/core/model_service/test_outlet_storage.py create mode 100644 test/sdk/core/model_service/test_protocol_diagnostics.py create mode 100644 test/sdk/core/model_service/test_resource_conversion.py create mode 100644 test/sdk/core/models/test_openai_llm_timeout_compat.py create mode 100644 test/sdk/monitor/test_monitoring_token_totals.py diff --git a/.agents/skills/nexent-frontend/references/components/runtime-output.md b/.agents/skills/nexent-frontend/references/components/runtime-output.md new file mode 100644 index 0000000000..f8fc6f63b6 --- /dev/null +++ b/.agents/skills/nexent-frontend/references/components/runtime-output.md @@ -0,0 +1,11 @@ +# Nexent output boundary and common conversation presentation + +`frontend/lib/nexentOutputAdapter.ts` normalizes public execution envelopes into the existing Nexent event protocol. It creates no React nodes or assistant-ui parts and does not select behavior by Core name. Live SSE and saved units use the same state machine; rendering remains owned by the original chat parsers and components. + +- Remove the M5 `runtime-output` renderer path. Reasoning, Markdown, ToolFallback and SubAgentContainer render all Core outputs. +- Ordinary model process text uses the established process event type. Preserve explicit provider reasoning separately in metadata and never invent reasoning text. +- Only a validated, sealed successful RunResult confirms final content. Failed/stopped/missing/repeated/order-invalid results retain partial and never become completed by EOF. +- Correlate tool results by turn, invocation and local ID. Tool error and child execution status are generic presentation inputs for every Core. +- Deserialize envelopes only at the saved-unit boundary. Live model JSON remains ordinary text. +- Unknown token counts remain explicitly unknown in the shared usage component; do not display zero as a substitute. +- Historical rendering uses saved events and persisted status, never the Agent's current Core selection. diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 8cd34e9399..36639f8453 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -1189,6 +1189,8 @@ def _get_skill_script_tools( async def create_model_config_list(tenant_id): + from services.model_runtime_snapshot_service import restore_runtime_model_snapshot + records = get_model_records({"model_type": "llm"}, tenant_id) model_list = [] extra_body = {"logprobs": True} if LLM_INCLUDE_LOGPROBS else None @@ -1204,6 +1206,7 @@ async def create_model_config_list(tenant_id): ) model_list.append( ModelConfig(cite_name=record["display_name"], + resolved_profile=restore_runtime_model_snapshot(record, tenant_id, strict=False), api_key=record.get("api_key", ""), model_name=model_name, url=record["base_url"], @@ -1243,6 +1246,7 @@ async def create_model_config_list(tenant_id): main_model_config.get("model_factory")) model_list.append( ModelConfig(cite_name="main_model", + resolved_profile=restore_runtime_model_snapshot(main_model_config, tenant_id, strict=False), api_key=main_model_config.get("api_key", ""), model_name=get_model_name_from_config(main_model_config) if main_model_config.get( "model_name") else "", @@ -1255,6 +1259,7 @@ async def create_model_config_list(tenant_id): extra_body=extra_body)) model_list.append( ModelConfig(cite_name="sub_model", + resolved_profile=restore_runtime_model_snapshot(main_model_config, tenant_id, strict=False), api_key=main_model_config.get("api_key", ""), model_name=get_model_name_from_config(main_model_config) if main_model_config.get( "model_name") else "", @@ -1305,6 +1310,15 @@ def _inject_plan_tools(tools: List[ToolConfig], enable_planning: bool) -> None: ]) +def build_agent_context_policy(enable_context_manager, tenant_policy=None, agent_policy=None, request_policy=None): + """Apply the saved CM switch at Agent precedence, retaining explicit request overrides.""" + mode = "adaptive_compact" if enable_context_manager else "passthrough" + return PolicyLayers.model_validate({ + "platform": {"processing_mode": mode}, "tenant": tenant_policy, + "agent": {**(agent_policy or {}), "processing_mode": mode}, "request": request_policy, + }) + + async def create_agent_config( agent_id, tenant_id, @@ -1872,14 +1886,10 @@ async def create_agent_config( f"skills_count={len(skills)}, " f"items={[f'{item.id}(type={item.type.value},priority={item.priority})' for item in context_items]}" ) - policy_layers = PolicyLayers.model_validate({ - "platform": { - "processing_mode": "adaptive_compact" if enable_context_manager else "passthrough" - }, - "tenant": tenant_config_manager.get_context_policy(tenant_id), - "agent": agent_info.get("context_policy"), - "request": request_context_policy, - }) + policy_layers = build_agent_context_policy( + enable_context_manager, tenant_config_manager.get_context_policy(tenant_id), + agent_info.get("context_policy"), request_context_policy, + ) effective_context_policy = resolve_policy(policy_layers) effective_processing_mode = getattr( effective_context_policy.processing_mode, @@ -2564,6 +2574,97 @@ def check_agent_tools(agent_config: AgentConfig): return list(used_mcp_urls) +def _apply_agent_model_overrides(model_list, *, agent_id, tenant_id, version_no): + """Apply only the selected Agent version's saved overrides to detached models.""" + _agent_info_for_override = search_agent_info_by_agent_id( + agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) + model_params_override = (_agent_info_for_override or {}).get("model_params_override") or {} + if model_params_override: + for model_id_str, override_entry in model_params_override.items(): + if not isinstance(override_entry, dict): + continue + try: + override_model_id = int(model_id_str) + except (ValueError, TypeError): + continue + override_model_info = get_model_by_model_id(override_model_id, tenant_id=tenant_id) + if not override_model_info: + continue + display_name = override_model_info.get("display_name") + if not display_name: + continue + for mc in model_list: + if mc.cite_name == display_name: + if override_entry.get("temperature") is not None: + mc.temperature = override_entry["temperature"] + if override_entry.get("top_p") is not None: + mc.top_p = override_entry["top_p"] + # v2.6.0: capacity fields are overridable per-agent. The + # authoritative consumer for request shaping is the W1/W2 + # resolution (agent-selected model), this keeps each + # ModelConfig consistent with its override entry. + for field in _OPERATOR_OVERRIDE_FIELDS: + if override_entry.get(field) is not None: + setattr(mc, field, override_entry[field]) + override_extra = override_entry.get("extra_params") + if override_extra and isinstance(override_extra, dict): + merged = dict(mc.extra_body or {}) + for k, v in override_extra.items(): + if k == "enable_thinking": + if isinstance(v, bool): + mc.enable_thinking = v + if not v: + mc.reasoning_effort = None + mc.reasoning_budget_tokens = None + continue + if k == "reasoning_effort": + if isinstance(v, str): + mc.reasoning_effort = v + continue + if k == "reasoning_budget_tokens": + if isinstance(v, int) and not isinstance(v, bool) and v > 0: + mc.reasoning_budget_tokens = v + continue + if k == "__custom__" and isinstance(v, dict): + for custom_key, custom_value in v.items(): + # A null custom value is an explicit + # removal marker: the agent opts out of a + # model-level custom param instead of + # inheriting it. + if custom_value is None: + merged.pop(custom_key, None) + else: + merged[custom_key] = custom_value + else: + merged[k] = v + mc.extra_body = merged if merged else None + if override_entry.get("reasoning_effort") is not None: + mc.reasoning_effort = override_entry["reasoning_effort"] + if override_entry.get("reasoning_budget_tokens") is not None: + budget = override_entry["reasoning_budget_tokens"] + if isinstance(budget, int) and not isinstance(budget, bool) and budget > 0: + mc.reasoning_budget_tokens = budget + break + + +def _capture_child_model_configs(config, models, tenant_id): + """Freeze each child outlet before any parent or request-scoped overlay.""" + from services.model_runtime_options_service import compile_model_runtime_profile + + for child in getattr(config, "worker_agents", ()): + child_models = [model.model_copy(deep=True) for model in models] + _apply_agent_model_overrides( + child_models, agent_id=child.agent_id, tenant_id=tenant_id, version_no=child.version_no, + ) + selected = next((model for model in child_models if model.cite_name == child.model_name), None) + if selected is None: + raise ValueError("CHILD_MODEL_OUTLET_NOT_FOUND") + if child.agent_core == "jiuwen": + selected.resolved_profile = compile_model_runtime_profile(selected, child) + child.runtime_model_config = selected.model_copy(deep=True) + _capture_child_model_configs(child, models, tenant_id) + + def apply_root_generation_overlay( model_list: List[ModelConfig], agent_config: AgentConfig, @@ -2729,75 +2830,10 @@ async def create_agent_run_info( # stored on ag_tenant_agent_t. We match each override entry to a ModelConfig # by display_name (the cite_name used in model_config_list) and merge the # override values on top of the model-level defaults. - _agent_info_for_override = search_agent_info_by_agent_id( - agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) - model_params_override = (_agent_info_for_override or {}).get("model_params_override") or {} - if model_params_override: - for model_id_str, override_entry in model_params_override.items(): - if not isinstance(override_entry, dict): - continue - try: - override_model_id = int(model_id_str) - except (ValueError, TypeError): - continue - override_model_info = get_model_by_model_id(override_model_id, tenant_id=tenant_id) - if not override_model_info: - continue - display_name = override_model_info.get("display_name") - if not display_name: - continue - for mc in model_list: - if mc.cite_name == display_name: - if override_entry.get("temperature") is not None: - mc.temperature = override_entry["temperature"] - if override_entry.get("top_p") is not None: - mc.top_p = override_entry["top_p"] - # v2.6.0: capacity fields are overridable per-agent. The - # authoritative consumer for request shaping is the W1/W2 - # resolution (agent-selected model), this keeps each - # ModelConfig consistent with its override entry. - for field in _OPERATOR_OVERRIDE_FIELDS: - if override_entry.get(field) is not None: - setattr(mc, field, override_entry[field]) - override_extra = override_entry.get("extra_params") - if override_extra and isinstance(override_extra, dict): - merged = dict(mc.extra_body or {}) - for k, v in override_extra.items(): - if k == "enable_thinking": - if isinstance(v, bool): - mc.enable_thinking = v - if not v: - mc.reasoning_effort = None - mc.reasoning_budget_tokens = None - continue - if k == "reasoning_effort": - if isinstance(v, str): - mc.reasoning_effort = v - continue - if k == "reasoning_budget_tokens": - if isinstance(v, int) and not isinstance(v, bool) and v > 0: - mc.reasoning_budget_tokens = v - continue - if k == "__custom__" and isinstance(v, dict): - for custom_key, custom_value in v.items(): - # A null custom value is an explicit - # removal marker: the agent opts out of a - # model-level custom param instead of - # inheriting it. - if custom_value is None: - merged.pop(custom_key, None) - else: - merged[custom_key] = custom_value - else: - merged[k] = v - mc.extra_body = merged if merged else None - if override_entry.get("reasoning_effort") is not None: - mc.reasoning_effort = override_entry["reasoning_effort"] - if override_entry.get("reasoning_budget_tokens") is not None: - budget = override_entry["reasoning_budget_tokens"] - if isinstance(budget, int) and not isinstance(budget, bool) and budget > 0: - mc.reasoning_budget_tokens = budget - break + _capture_child_model_configs(agent_config, model_list, tenant_id) + _apply_agent_model_overrides( + model_list, agent_id=agent_id, tenant_id=tenant_id, version_no=version_no, + ) # Request-scoped Workbench settings apply only to the root model. Static # children retain the model aliases from their published versions. # Static children keep their published aliases and never inherit this overlay. @@ -2863,6 +2899,14 @@ async def create_agent_run_info( ) selected_config.reasoning_budget_tokens = reasoning_budget_tokens + if getattr(agent_config, "agent_core", "smolagents") == "jiuwen": + from services.model_runtime_options_service import compile_model_runtime_profile + + selected_config = next((mc for mc in model_list if mc.cite_name == agent_config.model_name), None) + if selected_config is None: + raise ValueError("MODEL_OUTLET_NOT_FOUND") + selected_config.resolved_profile = compile_model_runtime_profile(selected_config, agent_config) + remote_mcp_list = await get_remote_mcp_server_list(tenant_id=tenant_id, is_need_auth=True) default_mcp_url = get_tenant_local_mcp_server(tenant_id) remote_mcp_list.append({ diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 7f5aba2741..bd7a226cf0 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -531,6 +531,22 @@ class RuntimeCapacityExceededError(RuntimeError): retry_after_seconds = 1 +class ModelOutletConflictError(ValueError): + """An absent, stale or unavailable outlet cannot be overwritten.""" + + def __init__(self): + super().__init__("MODEL_OUTLET_VERSION_CONFLICT") + + +class ModelOutletValidationError(ValueError): + """Invalid storage input without parameter or credential details.""" + + def __init__(self, category="MODEL_OUTLET_INVALID", path=""): + super().__init__("MODEL_OUTLET_INVALID") + self.category = category + self.path = path + + class RuntimeQueueTimeoutError(RuntimeError): """Raised when an accepted Runtime agent request expires in the queue.""" diff --git a/backend/consts/model.py b/backend/consts/model.py index 6bdb2fd346..45054c207b 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -8,6 +8,7 @@ ConfigDict, EmailStr, Field, + PrivateAttr, field_validator, model_validator, ) @@ -960,6 +961,8 @@ def reject_legacy_agent_fields(value): class AgentRequest(BaseModel): + _runtime_user_message_id: Optional[int] = PrivateAttr(default=None) + _runtime_user_message_index: Optional[int] = PrivateAttr(default=None) query: str conversation_id: Optional[int] = None history: Optional[List[HistoryItem]] = None diff --git a/backend/consts/model_profile.py b/backend/consts/model_profile.py new file mode 100644 index 0000000000..972fc56791 --- /dev/null +++ b/backend/consts/model_profile.py @@ -0,0 +1,53 @@ +"""Trusted management-side outlet selection contracts from the public model baseline.""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class ModelProfileSelection(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str = Field(min_length=1, max_length=200) + revision: str = Field(min_length=1, max_length=100) + + +class ModelProfileProviderInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + base_url: str = Field(min_length=1, max_length=500) + provider_id: str | None = Field(default=None, min_length=1, max_length=100) + ssl_verify: bool = True + timeout_seconds: int = Field(default=120, gt=0, lt=2**31) + concurrency_limit: int | None = Field(default=None, gt=0, lt=2**31) + + +class ModelOutletContextPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + combined_window: int | None = Field(default=None, gt=0, lt=2**31, strict=True) + input_limit: int | None = Field(default=None, gt=0, lt=2**31, strict=True) + output_limit: int | None = Field(default=None, gt=0, lt=2**31, strict=True) + + +class ModelOutletContextConfigurationPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + output_reserve_tokens: int | None = Field(default=None, gt=0, lt=2**31, strict=True) + tokenizer_family: str | None = Field(default=None, min_length=1, max_length=100) + capacity_source: str | None = Field(default=None, min_length=1, max_length=100) + capability_profile_version: str | None = Field(default=None, min_length=1, max_length=100) + + +class ModelProfilePreviewRequest(BaseModel): + """Selection inputs only; clients cannot author effective capabilities or bindings.""" + model_config = ConfigDict(extra="forbid") + selection_mode: Literal["auto", "manual"] = "auto" + model_profile: ModelProfileSelection | None = None + provider_profile: ModelProfileSelection | None = None + catalog_revision: str | None = Field(default=None, min_length=1, max_length=100) + provider_info: ModelProfileProviderInfo + request_model_id: str = Field(min_length=1, max_length=100) + display_name: str | None = Field(default=None, min_length=1, max_length=100) + protocol_id: Literal["openai_chat_completions"] = "openai_chat_completions" + authentication_scheme: Literal["bearer_token_v1", "no_auth_v1"] = "bearer_token_v1" + parameter_values: dict[str, Any] = Field(default_factory=dict) + extra_api_parameters: list[dict[str, Any]] = Field(default_factory=list) + context_limits: ModelOutletContextPatch | None = None + context_configuration: ModelOutletContextConfigurationPatch | None = None diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index cb40c759c3..f41c4b1623 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -1,4 +1,5 @@ import json +from contextlib import nullcontext from copy import deepcopy from datetime import datetime from typing import Any, Dict, List, Optional, TypedDict @@ -277,7 +278,7 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None, def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[str] = None, - status: str = 'completed') -> int: + status: str = 'completed', *, _session=None) -> int: """ Create a conversation message record @@ -294,7 +295,7 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ Returns: int: Newly created message ID (auto-increment ID) """ - with get_db_session() as session: + with (nullcontext(_session) if _session is not None else get_db_session()) as session: # Ensure conversation_id is integer type conversation_id = int(message_data['conversation_id']) message_idx = int(message_data['message_idx']) @@ -328,6 +329,29 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ return message_id +def append_conversation_user_message(conversation_id: int, content: str, minio_files, user_id: str) -> tuple[int, int]: + """Reserve an ordered user/assistant pair independently of client history.""" + with get_db_session() as session: + _lock_conversation_limit_scope(session, f"conversation-turn-limit:{conversation_id}") + allowed = session.scalar(select(ConversationRecord.conversation_id).where( + ConversationRecord.conversation_id == conversation_id, + ConversationRecord.created_by == user_id, + ConversationRecord.delete_flag == "N", + )) + if allowed is None: + raise ConversationNotFoundError("Conversation not found") + latest = session.scalar(select(func.max(ConversationMessage.message_index)).where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.delete_flag == "N", + )) + index = 0 if latest is None else max(0, (int(latest) // 2 + 1) * 2) + message_id = create_conversation_message({ + "conversation_id": conversation_id, "message_idx": index, "role": MESSAGE_ROLE["USER"], + "content": content, "minio_files": minio_files, + }, user_id, _session=session) + return message_id, index + + def create_message_units(message_units: List[Dict[str, Any]], message_id: int, conversation_id: int, user_id: Optional[str] = None) -> List[int]: """ @@ -2548,10 +2572,36 @@ def get_historical_context( if pending_user is not None: turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) + assistant_ids = [turn["assistant_message_id"] for turn in turns if turn["assistant_message_id"] > 0] + if assistant_ids: + records = session.execute(select( + ConversationMessageUnit.message_id, ConversationMessageUnit.unit_type, + ConversationMessageUnit.unit_content, ConversationMessageUnit.tool_call_id, + ConversationMessageUnit.invocation_id, + ).where( + ConversationMessageUnit.conversation_id == conversation_id, + ConversationMessageUnit.message_id.in_(assistant_ids), + ConversationMessageUnit.unit_type.in_([ + "tool", "tool-call", "tool_result", "execution_logs", "error", "file_artifact", + ]), + ConversationMessageUnit.unit_status == "completed", + ConversationMessageUnit.delete_flag == "N", + ).order_by(asc(ConversationMessageUnit.message_id), asc(ConversationMessageUnit.unit_index))).all() + by_message = {} + for record in records: + by_message.setdefault(record.message_id, []).append({ + "record_type": record.unit_type, "content": record.unit_content, + "tool_call_id": record.tool_call_id, "invocation_id": record.invocation_id, + }) + for turn in turns: + if turn["assistant_message_id"] in by_message: + turn["execution_records"] = by_message[turn["assistant_message_id"]] + summary_result = None if summary_record and summary_payload: summary_result = { "unit_id": summary_record.unit_id, **summary_payload, } - return {"history_summary": summary_result, "conversation_turns": turns} + return {"history_summary": summary_result, "conversation_turns": turns, + "current_user_message_index": current.message_index} diff --git a/backend/database/db_models.py b/backend/database/db_models.py index b7d4eae7d4..c81e6afbc9 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -425,6 +425,12 @@ class ModelRecord(TableBase): __tablename__ = "model_record_t" __table_args__ = {"schema": SCHEMA} + runtime_outlet_snapshot = Column( + JSONB, nullable=True, + comment="Management-owned canonical outlet row plus source fingerprint; NULL requires explicit provisioning. " + "Contains no credentials; validated by the shared profile codec, maximum 1 MiB.", + ) + model_id = Column(Integer, Sequence("model_record_t_model_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Model ID, unique primary key") model_repo = Column(String(100), doc="Model path address") diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 11487575ec..e0ba39c80a 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -12,7 +12,7 @@ logger = logging.getLogger("database.model_management_db") -def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str) -> bool: +def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str, *, runtime_profile_builder=None) -> bool: """ Create a model record @@ -45,7 +45,15 @@ def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str cleaned_data["extra_params"] = filter_extra_params(model_type, extra_params) # Build the insert statement + cleaned_data.pop("runtime_outlet_snapshot", None) stmt = insert(ModelRecord).values(cleaned_data) + if runtime_profile_builder is not None: + model_id = session.execute(stmt.returning(ModelRecord.model_id)).scalar_one() + record = as_dict(session.scalars(select(ModelRecord).where(ModelRecord.model_id == model_id)).one()) + snapshot = runtime_profile_builder(record) + session.execute(update(ModelRecord).where(ModelRecord.model_id == model_id) + .values(runtime_outlet_snapshot=snapshot)) + return True # Execute the insert statement result = session.execute(stmt) @@ -57,7 +65,8 @@ def update_model_record( model_id: int, update_data: Dict[str, Any], user_id: Optional[str] = None, - tenant_id: Optional[str] = None + tenant_id: Optional[str] = None, + *, runtime_profile_builder=None, ) -> bool: """ Update a model record @@ -84,6 +93,15 @@ def update_model_record( if tenant_id is not None: cleaned_data["tenant_id"] = tenant_id + current = None + if runtime_profile_builder is not None: + query = select(ModelRecord).where(ModelRecord.model_id == model_id, ModelRecord.delete_flag == "N") + if tenant_id is not None: + query = query.where(ModelRecord.tenant_id == tenant_id) + current = session.scalars(query.with_for_update()).first() + if current is None: + return False + # v2.6.0: filter extra_params to only allowed keys for this model type. # When model_type is not part of the update payload, look it up from the # existing record so filtering still applies. @@ -91,12 +109,18 @@ def update_model_record( if extra_params is not None: model_type = cleaned_data.get("model_type") if model_type is None: - model_type = session.scalars( + model_type = current.model_type if current is not None else session.scalars( select(ModelRecord.model_type).where( ModelRecord.model_id == model_id) ).first() cleaned_data["extra_params"] = filter_extra_params(model_type, extra_params) + cleaned_data.pop("runtime_outlet_snapshot", None) + if runtime_profile_builder is not None: + record = {**as_dict(current), **{key: value for key, value in cleaned_data.items() + if key not in {"update_time"}}} + cleaned_data["runtime_outlet_snapshot"] = runtime_profile_builder(record) + # Build the update statement stmt = update(ModelRecord).where( ModelRecord.model_id == model_id diff --git a/backend/database/model_runtime_profile_db.py b/backend/database/model_runtime_profile_db.py new file mode 100644 index 0000000000..131e7cbf8d --- /dev/null +++ b/backend/database/model_runtime_profile_db.py @@ -0,0 +1,31 @@ +"""Explicit, bounded migration of trusted model snapshots in the existing model row.""" + +from database.client import as_dict, get_db_session +from database.db_models import ModelRecord +from sqlalchemy import select, update + + +def provision_runtime_outlet_batch(builder, *, after_model_id=0, batch_size=100, tenant_id=None, dry_run=True): + """Serialize with management saves; retain idempotent snapshots and sanitized failures.""" + if type(batch_size) is not int or not 1 <= batch_size <= 1000: + raise ValueError("MODEL_OUTLET_BATCH_SIZE_INVALID") + with get_db_session() as session: + query = select(ModelRecord).where( + ModelRecord.model_id > after_model_id, ModelRecord.delete_flag == "N", + ModelRecord.model_type.in_(("llm", "chat")), + ).order_by(ModelRecord.model_id).limit(batch_size) + if tenant_id is not None: + query = query.where(ModelRecord.tenant_id == tenant_id) + records = session.scalars(query.with_for_update()).all() + results = [] + for model in records: + record = as_dict(model) + snapshot = builder(record) + status = "failed" if not snapshot or snapshot.get("error") else ( + "ready" if snapshot == record.get("runtime_outlet_snapshot") else "migrated" + ) + if not dry_run and snapshot != record.get("runtime_outlet_snapshot"): + session.execute(update(ModelRecord).where(ModelRecord.model_id == model.model_id) + .values(runtime_outlet_snapshot=snapshot, updated_by="runtime-outlet-migration")) + results.append({"model_id": model.model_id, "status": status}) + return results diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 32b061547a..b023db2509 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -116,6 +116,7 @@ transform_skill_files_to_standard_format as _transform_skill_files_to_standard_format, ) from utils.context_utils import build_authorized_context_input +from utils.runtime_output import RuntimeOutputState, is_runtime_event from utils.config_utils import get_model_name_from_config, tenant_config_manager # Monitoring utilities: bind Agent metadata once at the request boundary. @@ -393,6 +394,7 @@ async def _stream_agent_chunks( # matching the legacy batch merge logic. _MERGEABLE_TYPES = { ProcessType.FINAL_ANSWER.value, + "model_output", ProcessType.MODEL_OUTPUT_CODE.value, ProcessType.MODEL_OUTPUT_THINKING.value, ProcessType.MODEL_OUTPUT_DEEP_THINKING.value, @@ -408,6 +410,8 @@ async def _stream_agent_chunks( buffered_image_url_set: set[str] = set() buffered_automation_proposals: list[dict[str, Any]] = [] final_answer_content = "" + runtime_output = RuntimeOutputState( + required=getattr(agent_run_info.agent_config, "agent_core", None) == "jiuwen") # Determine if we're in resume mode is_resume_mode = resume_from_unit_index > 0 @@ -421,9 +425,13 @@ async def _stream_agent_chunks( for item in (getattr(agent_request, "history", None) or ()) if item.role == MESSAGE_ROLE["USER"] ) + saved_index = getattr(agent_request, "_runtime_user_message_index", None) + assistant_index = ( + saved_index + 1 if type(saved_index) is int and saved_index >= 0 else user_role_count * 2 + 1 + ) assistant_message_req = MessageRequest( conversation_id=agent_request.conversation_id, - message_idx=user_role_count * 2 + 1, + message_idx=assistant_index, role=MESSAGE_ROLE["ASSISTANT"], message=[], minio_files=None, @@ -499,6 +507,7 @@ async def _iter_run_chunks(): try: data = json.loads(chunk) chunk_type = data.get("type") + runtime_output.observe(data) chunk_content = data.get("content", "") or "" if chunk_type == "final_answer": has_body = resolve_answer_completion(buffered_units, data) @@ -765,10 +774,13 @@ async def _iter_run_chunks(): "invocation_id": data.get("invocation_id"), "_attempt_id": data.get("attempt_id"), "_step_id": data.get("step_id"), + "_block_id": data.get("block_id"), + **({"_runtime_envelope": dict(data)} if is_runtime_event(data) else {}), "mergeable": mergeable, } if mergeable: - current_unit["_content_fragments"] = [persisted_content] + current_unit["_content_fragments"] = [ + chunk_content if is_runtime_event(data) else persisted_content] current_unit["content"] = "" current_unit["unit_content"] = "" buffered_units.append(current_unit) @@ -824,6 +836,11 @@ async def _iter_run_chunks(): # the worker outcome still authoritatively marks the run failed. terminal_status = outcome + if runtime_output.required: + terminal_status = runtime_output.status + if outcome in {"failed", "stopped"}: + terminal_status = outcome + try: skill_file_payloads = list(captured_skill_files.values()) if skill_file_payloads: @@ -910,6 +927,8 @@ async def _iter_run_chunks(): persistence_bytes = _finalize_buffered_unit_fragments(buffered_units) final_answer_content = next((unit["content"] for unit in reversed(buffered_units) if unit["type"] == "final_answer" and not unit.get("invocation_id")), "") + if runtime_output.required: + final_answer_content = runtime_output.final_content if terminal_status == "completed" else "" logger.info( "Finalizing assistant persistence conversation=%s units=%s bytes=%s", agent_request.conversation_id, @@ -1184,6 +1203,32 @@ async def prepare_agent_run( Prepare for an agent run by creating context and run info, and registering the run. """ + historical_context = None + if not agent_request.is_debug and agent_request.conversation_id is not None: + current_message_id = getattr(agent_request, "_runtime_user_message_id", None) + if type(current_message_id) is not int: + current_message_id = get_current_run_user_message_id( + agent_request.conversation_id, user_id + ) + if not isinstance(current_message_id, int) or isinstance( + current_message_id, bool + ): + current_message_id = None + logger.warning( + "Current user message boundary is unavailable; rejecting historical run" + ) + if current_message_id is not None: + historical_context = load_historical_context( + agent_request.conversation_id, current_message_id, user_id, tenant_id + ) + if not agent_request.is_debug and agent_request.conversation_id is not None and historical_context is None: + raise ForbiddenError("Authorized conversation history boundary is unavailable") + + if isinstance(historical_context, dict): + saved_index = historical_context.get("current_user_message_index") + if type(saved_index) is int and saved_index >= 0: + agent_request._runtime_user_message_index = saved_index + memory_context = build_memory_context( user_id, tenant_id, agent_request.agent_id, skip_query=not allow_memory_search ) @@ -1255,22 +1300,6 @@ async def prepare_agent_run( None, ) - historical_context = None - if not agent_request.is_debug and agent_request.conversation_id is not None: - current_message_id = get_current_run_user_message_id( - agent_request.conversation_id, user_id - ) - if not isinstance(current_message_id, int) or isinstance( - current_message_id, bool - ): - current_message_id = None - logger.warning( - "Current user message boundary is unavailable; historical checkpoint loading skipped" - ) - if current_message_id is not None: - historical_context = load_historical_context( - agent_request.conversation_id, current_message_id, user_id, tenant_id - ) agent_run_info.context_input = build_authorized_context_input( agent_run_info, historical_context ) diff --git a/backend/scripts/migrate_runtime_outlets.py b/backend/scripts/migrate_runtime_outlets.py new file mode 100644 index 0000000000..4c759c6744 --- /dev/null +++ b/backend/scripts/migrate_runtime_outlets.py @@ -0,0 +1,36 @@ +"""Provision legacy LLMs explicitly after applying the runtime outlet schema migration.""" + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "sdk")) + + +def main(): + from database.model_runtime_profile_db import provision_runtime_outlet_batch + from services.model_runtime_provisioning_service import ( + build_runtime_outlet_snapshot, + ) + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="Persist snapshots; default is a dry run") + parser.add_argument("--tenant-id") + args = parser.parse_args() + cursor, counts = 0, {"migrated": 0, "ready": 0, "failed": 0} + while True: + batch = provision_runtime_outlet_batch(build_runtime_outlet_snapshot, after_model_id=cursor, + tenant_id=args.tenant_id, dry_run=not args.apply) + if not batch: + break + for item in batch: + counts[item["status"]] += 1 + cursor = batch[-1]["model_id"] + print(json.dumps({"dry_run": not args.apply, **counts})) + return 1 if counts["failed"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index adb4d6e6c2..7a4d9cca04 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -9,10 +9,11 @@ from nexent.monitor import get_monitoring_manager, set_monitoring_context, set_monitoring_operation from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING, MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE -from consts.model import AgentRequest, MessageRequest, MessageUnit +from consts.model import AgentRequest, MessageRequest from consts.exceptions import AppException, ConversationNotFoundError, ValidationError from database.conversation_db import ( CHAT_MODE_VALUES, + append_conversation_user_message, create_conversation, create_conversation_message, create_message_unit, @@ -275,23 +276,11 @@ def save_conversation_user(request: AgentRequest, user_id: str, tenant_id: str) Note: conversation_message_unit_t only stores assistant message content. User messages do not need unit records. """ - user_role_count = sum(1 for item in getattr( - request, "history", []) if item.role == MESSAGE_ROLE["USER"]) - - # Strip the locale-specific current-time prefix before persisting so historical - # messages do not show the time marker. The prefix is injected by - # run_agent_stream for the LLM call only. - raw_query = strip_current_time_prefix(request.query) - - conversation_req = MessageRequest( - conversation_id=request.conversation_id, - message_idx=user_role_count * 2, - role=MESSAGE_ROLE["USER"], - message=[MessageUnit(type="string", content=raw_query)], - minio_files=request.minio_files, + message_id, message_index = append_conversation_user_message( + request.conversation_id, strip_current_time_prefix(request.query), request.minio_files, user_id, ) - save_message( - conversation_req, user_id=user_id, tenant_id=tenant_id) + request._runtime_user_message_id = message_id + request._runtime_user_message_index = message_index def save_conversation_assistant(request: AgentRequest, messages: List[str], user_id: str, tenant_id: str): diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index d93d3bc4f5..05b5b9d539 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -10,7 +10,7 @@ from services.voice_service import get_voice_service from consts.const import LOCALHOST_IP, LOCALHOST_NAME, DOCKER_INTERNAL_HOST from consts.model import ModelConnectStatusEnum -from database.model_management_db import get_model_by_display_name, update_model_record +from database.model_management_db import get_model_by_display_name from utils.config_utils import get_model_name_from_config logger = logging.getLogger("model_health_service") @@ -23,6 +23,13 @@ EMBEDDING_TYPES = {"embedding", "multi_embedding"} +def update_model_record(*args, **kwargs): + """Keep health writes synchronized with trusted runtime snapshots.""" + from services.model_runtime_provisioning_service import update_model_record as persist + + return persist(*args, **kwargs) + + def _normalize_embedding_url(base_url: str) -> str: """Append /embeddings suffix to base_url if not already present. diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index d69870a10b..2d1e57552d 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -23,20 +23,21 @@ ) from database.model_management_db import ( - create_model_record, delete_model_record, get_model_by_model_id, get_model_by_name_factory, get_models_by_display_name, get_model_records, get_models_by_tenant_factory_type, - update_model_record ) from database.tenant_config_db import ( get_single_config_info, insert_config, update_config_by_tenant_config_id, ) + + +from services.model_runtime_provisioning_service import create_model_record, update_model_record from services.model_provider_service import ( prepare_model_dict, merge_existing_model_attributes, @@ -1158,6 +1159,7 @@ async def list_models_for_tenant(tenant_id: str): } for record in records: + record.pop("runtime_outlet_snapshot", None) _enrich_model_reasoning_capability(record) record["model_name"] = add_repo_to_name( model_repo=record["model_repo"], @@ -1185,6 +1187,7 @@ async def list_llm_models_for_tenant(tenant_id: str): records = get_model_records({"model_type": "llm"}, tenant_id) result: List[Dict[str, Any]] = [] for record in records: + record.pop("runtime_outlet_snapshot", None) _enrich_model_reasoning_capability(record) result.append({ "model_id": record["model_id"], diff --git a/backend/services/model_outlet_migration_service.py b/backend/services/model_outlet_migration_service.py new file mode 100644 index 0000000000..1e0c5023c3 --- /dev/null +++ b/backend/services/model_outlet_migration_service.py @@ -0,0 +1,194 @@ +"""One-time legacy LLM snapshot construction; database ownership stays in the DAL.""" + +from copy import deepcopy + +from consts.exceptions import ModelOutletValidationError +from consts.model_profile import ModelProfilePreviewRequest +from nexent.core.agents.agent_model import ModelConfig +from nexent.core.model_service.compatibility.legacy_config import ( + legacy_agent_request_parameters, + legacy_thinking_enabled, +) +from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.registry import model_service_references +from pydantic import ValidationError +from services.model_profile_service import build_outlet_draft +from utils.model_name_utils import add_repo_to_name + +MIGRATION_VERSION = "legacy_llm_outlet_v2" + + +def _pointer(key): + return key.replace("~", "~0").replace("/", "~1") + + +def _leaves(value, prefix=""): + for key, child in value.items(): + path = prefix + "/" + _pointer(key) + if isinstance(child, dict) and child: + yield from _leaves(child, path) + else: + yield path, child + + +def _rule(key, value, path=None, transform="identity_v1"): + schema = {"type": {bool: "boolean", int: "integer", float: "number", str: "string", + list: "array", dict: "object", type(None): "null"}[type(value)]} + # Sampling NULL is an explicit provider default, not a missing stored key. + if key in {"temperature", "top_p"}: + schema = {"type": ["number", "null"]} + result = {"key": key, "schema": schema, "visible": path is not None, + "editable": path is not None, "required": False, "allowed_scopes": ["outlet", "conversation"], + "ui_group": "reasoning" if key.startswith("reasoning") or key == "enable_thinking" else "advanced", + "owner": "combination", "source": {"ref": MIGRATION_VERSION, "version": 1}} + if path is not None: + result["api_binding"] = {"path": path, "transform_ref": transform, "transform_version": 1} + return result + + +def _parameters(profile, body, legacy_extra): + mapped = {"/temperature": "temperature", "/top_p": "top_p", "/max_tokens": "max_output_tokens", + "/reasoning_effort": "reasoning_effort", "/thinking_budget": "reasoning_budget_tokens", + "/thinking/budget_tokens": "reasoning_budget_tokens", "/enable_thinking": "enable_thinking", + "/chat_template_kwargs/enable_thinking": "enable_thinking", "/thinking/type": "enable_thinking"} + rules, values, extras = {}, {}, [] + for path, value in _leaves(body): + key = mapped.get(path) + if key is None: + extras.append({"path": path, "value": value}) + continue + if key in values: + raise ModelOutletValidationError(category="LEGACY_PARAMETER_BINDING_CONFLICT", path=key) + transform = "identity_v1" + if path == "/thinking/type": + if value not in ("enabled", "disabled"): + raise ModelOutletValidationError(category="LEGACY_THINKING_FORMAT_INVALID", path=key) + value = value == "enabled" + transform = "thinking_toggle_v1" + values[key], rules[key] = value, _rule(key, value, path, transform) + unbound = [] + for key in ("enable_thinking", "reasoning_effort", "reasoning_budget_tokens"): + if key not in values and key in legacy_extra: + values[key], rules[key] = legacy_extra[key], _rule(key, legacy_extra[key]) + unbound.append(key) + # Keep unconfigured runtime-option declarations without introducing new defaults. + for declaration in profile.parameter_rules: + key = declaration["key"] + if key not in rules: + declaration = deepcopy(declaration) + for field in ("default_value", "fixed_value", "condition"): + declaration.pop(field, None) + declaration["required"] = False + rules[key] = declaration + if "max_output_tokens" not in rules and "max_tokens" not in rules: + declaration = _rule("max_output_tokens", 1, "/max_tokens") + declaration["schema"]["minimum"] = 1 + rules["max_output_tokens"] = declaration + if "stop" not in rules: + declaration = _rule("stop", [], "/stop") + declaration.update(visible=False, allowed_scopes=["outlet"], ui_group="runtime") + declaration["schema"] = {"type": ["string", "array"], "items": {"type": "string"}} + rules["stop"] = declaration + if "response_format" not in rules: + declaration = _rule("response_format", {}, "/response_format") + declaration.update(visible=False, allowed_scopes=["outlet"], ui_group="runtime") + rules["response_format"] = declaration + return list(rules.values()), values, extras, unbound + + +def build_legacy_outlet_snapshot(row, *, legacy_capability, migrated_at, from_version="pre-profile"): + """Preserve effective legacy values and references; never read current TOML for saved rows.""" + refs = model_service_references() + if row.get("profile_schema_version") is not None: + return decode_resolved_model_profile(row, registered_references=refs) + if row.get("model_type") not in {"llm", "chat"}: + raise ModelOutletValidationError(category="LEGACY_MODEL_TYPE_UNSUPPORTED") + try: + name = add_repo_to_name(row.get("model_repo") or "", row["model_name"]) + request = ModelProfilePreviewRequest(request_model_id=name, display_name=row.get("display_name") or name, + authentication_scheme="bearer_token_v1" if row.get("api_key") else "no_auth_v1", + provider_info={"base_url": row["base_url"], "provider_id": row.get("model_factory") or "custom", + "ssl_verify": row.get("ssl_verify") if row.get("ssl_verify") is not None else True, + "timeout_seconds": row.get("timeout_seconds") or 60, + "concurrency_limit": row.get("concurrency_limit")}) + profile, _ = build_outlet_draft(request, row["tenant_id"]) + body = legacy_agent_request_parameters(row, model_id=name, capability=legacy_capability) + old = ModelConfig(cite_name=request.display_name, model_name=name, url=row["base_url"], + max_tokens=row.get("max_tokens"), max_output_tokens=row.get("max_output_tokens"), + context_window_tokens=row.get("context_window_tokens")) + if row.get("default_output_reserve_tokens") is not None: + # The legacy W2 budget supplied this value to the actual model request. + body["max_tokens"] = row["default_output_reserve_tokens"] + elif old.max_output_tokens is not None: + body.setdefault("max_tokens", old.max_output_tokens) + data = profile.model_dump(mode="json") + rules, values, extras, unbound = _parameters(profile, body, row.get("extra_params") or {}) + data.update(outlet_id=row["model_id"], config_revision=row.get("config_revision") or 1, + parameter_rules=rules, parameter_values=values, extra_api_parameters=extras) + data["connection"]["credential_ref"]["model_id"] = row["model_id"] + defaults = [] + context = data["capabilities"]["context"] + for field, column in (("combined_window", "context_window_tokens"), ("input_limit", "max_input_tokens"), + ("output_limit", "max_output_tokens")): + if row.get(column) is not None: + context[field] = row[column] + if context["combined_window"] is None: + context["combined_window"] = 200000 + defaults.append("capabilities.context.combined_window") + for field in ("input_limit", "output_limit"): + if (row.get({"input_limit": "max_input_tokens", "output_limit": "max_output_tokens"}[field]) is None + and context[field] is not None and context[field] > context["combined_window"]): + context[field] = None + for field, column in (("output_reserve_tokens", "default_output_reserve_tokens"), + ("tokenizer_family", "tokenizer_family"), ("capacity_source", "capacity_source"), + ("capability_profile_version", "capability_profile_version")): + data["context_configuration"][field] = row.get(column) + controls = [] + if "enable_thinking" in values: + controls.append({"mode": "toggle", "parameter_key": "enable_thinking", "values": [False, True]}) + if "reasoning_effort" in values: + controls.append({"mode": "effort", "parameter_key": "reasoning_effort", "values": [values["reasoning_effort"]]}) + if type(values.get("reasoning_budget_tokens")) is int: + value = values["reasoning_budget_tokens"] + controls.append({"mode": "budget_tokens", "parameter_key": "reasoning_budget_tokens", "min": 0, + "max": None, "special_values": [value] if value < 0 else []}) + reasoning = data["capabilities"]["reasoning"] + declared_reasoning = row.get("reasoning_supported") + enabled_legacy = legacy_thinking_enabled(row.get("extra_params")) + if declared_reasoning is True or (declared_reasoning is None and enabled_legacy): + reasoning.update(status="supported", controls=controls or reasoning["controls"]) + elif declared_reasoning is False: + reasoning.update(status="unsupported", controls=None, response_paths=[]) + elif reasoning["status"] == "unknown": + reasoning.update(status="unsupported", controls=None, response_paths=[]) + defaults.append("capabilities.reasoning.status") + if reasoning["status"] == "supported" and controls: + reasoning["controls"] = controls + if reasoning["controls"] is not None: + reasoning["controls"] = [control for control in reasoning["controls"] + if not (control["parameter_key"] in unbound and values.get(control["parameter_key"]) is None)] + tools = data["capabilities"]["tools"] + if row.get("native_tool_call_supported") is not None: + tools["status"] = "supported" if row["native_tool_call_supported"] else "unsupported" + elif tools["status"] == "unknown": + tools["status"] = "unsupported" + defaults.append("capabilities.tools.status") + data["provenance"]["provider_metadata"]["migration"] = { + "origin": "legacy_model_record", "from_version": from_version, "version": MIGRATION_VERSION, + "migrated_at": migrated_at, "reviewed_revision": None, "defaulted_fields": defaults, + "unmatched_atoms": [kind for kind, key in (("model", profile.model_profile_key), + ("provider", profile.provider_key)) if key is None], + "unbound_legacy_parameters": unbound, + } + resolved = ResolvedModelProfile.model_validate(data) + encode_resolved_model_profile(resolved, registered_references=refs, + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + return resolved + except ModelOutletValidationError: + raise + except (KeyError, TypeError, ValueError, ValidationError): + raise ModelOutletValidationError(category="LEGACY_OUTLET_INVALID") from None diff --git a/backend/services/model_profile_service.py b/backend/services/model_profile_service.py new file mode 100644 index 0000000000..6389df50aa --- /dev/null +++ b/backend/services/model_profile_service.py @@ -0,0 +1,143 @@ +"""Read-only profile discovery for LLM onboarding from the pinned local catalog.""" + +from functools import lru_cache + +from consts.exceptions import ModelOutletConflictError, ModelOutletValidationError +from nexent.core.model_service.profiles.resolver import load_profile_catalog +from nexent.core.model_service.registry import model_service_references + + +@lru_cache(maxsize=1) +def get_model_profile_catalog(): + """Load one immutable catalog per process; deployment updates invalidate it by restart.""" + return load_profile_catalog() + + +def _select_profile(catalog, request, kind): + """Use explicit versioned selections or an unambiguous exact automatic suggestion.""" + from urllib.parse import urlsplit + + entries = catalog.models if kind == "model" else catalog.providers + explicit = request.model_profile if kind == "model" else request.provider_profile + if explicit is not None: + atom = entries.get(explicit.key) + if atom is None: + raise ModelOutletValidationError() + if atom.revision != explicit.revision: + raise ModelOutletConflictError() + return explicit.key, [explicit.key] + if request.selection_mode == "manual": + return None, [] + if kind == "model": + candidates = [key for key, atom in entries.items() + if request.request_model_id == key or request.request_model_id == key.split("/", 1)[-1] + or request.request_model_id in atom.aliases] + else: + endpoint = urlsplit(request.provider_info.base_url) + candidates = [] + for key, atom in entries.items(): + if request.provider_info.provider_id == key: + candidates.append(key) + continue + hints = list(atom.endpoint_hints) + suggested = atom.source.connection_suggestions.get("base_url") + if suggested: + hints.append(suggested) + if any((endpoint.scheme, endpoint.netloc, endpoint.path.rstrip("/")) == + (urlsplit(hint).scheme, urlsplit(hint).netloc, urlsplit(hint).path.rstrip("/")) for hint in hints): + candidates.append(key) + return (candidates[0] if len(candidates) == 1 else None), sorted(candidates) + + +def build_outlet_draft(request, tenant_id, *, require_revision=False): + """Recompose trusted policy at preview/save; never accept a client effective profile.""" + from urllib.parse import urlsplit + + from nexent.core.model_service.profiles.composition import ( + ProfileCompositionError, + compose_provider_model_policy, + ) + from nexent.core.model_service.profiles.contracts import VersionedReference + from nexent.core.model_service.profiles.outlet_resolution import ( + OutletResolutionError, + construct_resolved_model_profile, + ) + from nexent.core.model_service.profiles.parameter_schema import ( + ParameterValidationError, + ) + from nexent.core.model_service.profiles.protocol_parameters import ( + openai_chat_parameter_rules, + ) + from nexent.core.model_service.profiles.request_parameters import ( + apply_extra_api_parameters, + ) + from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, + ) + from pydantic import ValidationError + + catalog = get_model_profile_catalog() + if require_revision and request.catalog_revision is None: + raise ModelOutletValidationError() + if request.catalog_revision is not None and request.catalog_revision != catalog.revision: + raise ModelOutletConflictError() + try: + endpoint = urlsplit(request.provider_info.base_url) + if (endpoint.scheme not in ("http", "https") or not endpoint.hostname + or endpoint.username is not None or endpoint.password is not None or endpoint.fragment): + raise ModelOutletValidationError() + _ = endpoint.port + provider_key, providers = _select_profile(catalog, request, "provider") + model_key, models = _select_profile(catalog, request, "model") + refs = model_service_references() + bindings = {key: values[0].model_dump() for key, values in refs.items() if key != "parameter_transform"} + bindings["auth"] = next(ref.model_dump() for ref in refs["auth"] if ref.ref == request.authentication_scheme) + if bindings["protocol"]["ref"] != request.protocol_id: + raise ModelOutletValidationError() + credential_kind = "none" if request.authentication_scheme == "no_auth_v1" else "model_record" + policy = compose_provider_model_policy( + catalog, provider_key=provider_key, model_profile_key=model_key, + request_model_id=request.request_model_id, base_bindings=bindings, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=refs, submitted_values=request.parameter_values, + protocol_parameter_rules=openai_chat_parameter_rules(), + ) + apply_extra_api_parameters({}, request.extra_api_parameters, + parameter_paths=[item["api_binding"]["path"] for item in policy.parameter_rules.declarations + if item.get("api_binding")], + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + profile = construct_resolved_model_profile( + policy, catalog, tenant_id=tenant_id, display_name=request.display_name or request.request_model_id, + model_type="llm", provider_id=request.provider_info.provider_id or provider_key or "custom", + connection=OutletConnection( + **request.provider_info.model_dump(exclude={"provider_id"}), + credential_ref={"kind": credential_kind, "model_id": None}, + ), + connection_rules=ConnectionRules(credential_kind=credential_kind, relative_path="chat/completions", + method="POST", headers={}), + context_configuration=ContextConfiguration(output_reserve_tokens=None, tokenizer_family=None, + capacity_source="profile", capability_profile_version=None), + registered_references=refs, extra_api_parameters=request.extra_api_parameters, + ) + if request.context_limits is not None or request.context_configuration is not None: + from nexent.core.model_service.profiles.resolved_profile import ( + ResolvedModelProfile, + ) + + data = profile.model_dump(mode="json") + if request.context_limits is not None: + data["capabilities"]["context"].update(request.context_limits.model_dump(exclude_unset=True)) + if request.context_configuration is not None: + data["context_configuration"].update(request.context_configuration.model_dump(exclude_unset=True)) + profile = ResolvedModelProfile.model_validate(data) + return profile, {"models": models, "providers": providers} + except ModelOutletConflictError: + raise + except (ProfileCompositionError, ParameterValidationError) as error: + raise ModelOutletValidationError(error.category, error.path) from None + except OutletResolutionError as error: + raise ModelOutletValidationError(error.category) from None + except (ValidationError, ValueError, StopIteration): + raise ModelOutletValidationError() from None diff --git a/backend/services/model_runtime_options_service.py b/backend/services/model_runtime_options_service.py new file mode 100644 index 0000000000..d6c88376f6 --- /dev/null +++ b/backend/services/model_runtime_options_service.py @@ -0,0 +1,88 @@ +"""Compile legacy request options against a detached saved outlet.""" + +from copy import deepcopy + + +def compile_model_runtime_profile(model_config, agent_config): + """Use saved rules only; never re-resolve a model name against the catalog.""" + from nexent.core.model_service.compatibility.invocation_options import ( + LegacyInvocationOptions, + ) + from nexent.core.model_service.profiles.request_parameters import ( + apply_extra_api_parameters, + ) + + profile = model_config.resolved_profile + if profile is None: + raise ValueError("MODEL_OUTLET_PROVISIONING_REQUIRED") + rules = {rule["key"]: rule for rule in profile.parameter_rules} + extra = deepcopy(model_config.extra_body or {}) + submitted = {} + fields = ("temperature", "top_p", "enable_thinking", "reasoning_effort", "reasoning_budget_tokens") + for key in fields: + value = getattr(model_config, key) + if key == "enable_thinking" and value is False and key not in profile.parameter_values: + # The legacy boolean default alone is not an explicit provider override. + continue + if key not in rules: + if value is not None and (key not in {"enable_thinking"} or value is True): + raise ValueError("MODEL_OUTLET_OVERRIDE_UNDECLARED") + continue + if value is not None or key in {"temperature", "top_p"}: + if not rules[key].get("api_binding") and value != profile.parameter_values.get(key): + raise ValueError("MODEL_OUTLET_OVERRIDE_UNBOUND") + submitted[key] = value + budget = agent_config.context_budget_snapshot + output = budget.requested_output_tokens if budget else ( + agent_config.requested_output_tokens or model_config.default_output_reserve_tokens + or model_config.max_output_tokens + ) + if output is not None: + submitted["max_tokens"] = output + + # Custom legacy API fields override ordinary aliases, but never a resolved + # output budget. Interpret only saved bindings, without provider inference. + for key, rule in rules.items(): + binding = rule.get("api_binding") + if not binding: + continue + tokens = [token.replace("~1", "/").replace("~0", "~") for token in binding["path"].split("/")[1:]] + parent = extra + ancestors = [] + for token in tokens[:-1]: + if isinstance(parent, dict): + ancestors.append((parent, token)) + parent = parent.get(token) if isinstance(parent, dict) else None + if not isinstance(parent, dict) or tokens[-1] not in parent: + continue + value = parent.pop(tokens[-1]) + for container, token in reversed(ancestors): + if container.get(token) == {}: + container.pop(token) + else: + break + if binding["transform_ref"] == "thinking_toggle_v1": + if value not in {"enabled", "disabled"}: + raise ValueError("MODEL_OUTLET_THINKING_FORMAT_INVALID") + value = value == "enabled" + submitted[key] = value + resolved = LegacyInvocationOptions(parameters=submitted).resolve(profile) + + for key in fields: + extra.pop(key, None) + + def leaves(value, prefix=""): + for key, child in value.items(): + path = prefix + "/" + key.replace("~", "~0").replace("/", "~1") + if isinstance(child, dict) and child: + yield from leaves(child, path) + else: + yield {"path": path, "value": child} + + extras = list(leaves(extra)) + apply_extra_api_parameters({}, extras, + parameter_paths=[rule["api_binding"]["path"] for rule in rules.values() if rule.get("api_binding")], + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + data = resolved.model_dump() + data["extra_api_parameters"] = extras + return type(profile).model_validate(data) diff --git a/backend/services/model_runtime_provisioning_service.py b/backend/services/model_runtime_provisioning_service.py new file mode 100644 index 0000000000..f44911f80c --- /dev/null +++ b/backend/services/model_runtime_provisioning_service.py @@ -0,0 +1,108 @@ +"""Management-only compatibility provisioning of canonical model outlet snapshots.""" + +import hashlib +import json +from datetime import UTC, datetime + +SOURCE_FIELDS = ( + "model_id", "tenant_id", "model_type", "model_repo", "model_name", "model_factory", "display_name", + "base_url", "ssl_verify", "timeout_seconds", "concurrency_limit", "max_tokens", "max_output_tokens", + "context_window_tokens", "max_input_tokens", "default_output_reserve_tokens", "tokenizer_family", + "capacity_source", "capability_profile_version", "temperature", "top_p", "extra_params", + "native_tool_call_supported", "reasoning_supported", +) +MAX_RUNTIME_SNAPSHOT_BYTES = 1024 * 1024 + + +def model_source_fingerprint(record): + """Detect unsynchronized legacy writes without storing or hashing credentials.""" + source = {key: record.get(key) for key in SOURCE_FIELDS} + source["uses_credential"] = bool(record.get("api_key")) + return hashlib.sha256(json.dumps(source, sort_keys=True, ensure_ascii=False, + allow_nan=False).encode("utf-8")).hexdigest() + + +def build_runtime_outlet_snapshot(record): + """Run only on management save/migration, never in the Agent runtime path.""" + if record.get("model_type") not in {"llm", "chat"}: + return None + from configs.model_catalog_loader import resolve_reasoning_capability + from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + encode_resolved_model_profile, + ) + from nexent.core.model_service.registry import model_service_references + from services.model_outlet_migration_service import build_legacy_outlet_snapshot + from utils.model_name_utils import add_repo_to_name + + fingerprint = model_source_fingerprint(record) + current = record.get("runtime_outlet_snapshot") or {} + if not isinstance(current, dict): + current = {} + if current.get("source_fingerprint") == fingerprint and current.get("row"): + from services.model_runtime_snapshot_service import ( + restore_runtime_model_snapshot, + ) + + if restore_runtime_model_snapshot(record, record["tenant_id"], strict=False) is not None: + return current + previous_row = current.get("row") + previous_revision = current.get("revision") + if type(previous_revision) is not int: + previous_revision = previous_row.get("config_revision", 0) if isinstance(previous_row, dict) else 0 + revision = (previous_revision if type(previous_revision) is int and previous_revision > 0 else 0) + 1 + try: + name = add_repo_to_name(record.get("model_repo") or "", record["model_name"]) + previous = None + if isinstance(current.get("row"), dict): + try: + previous = decode_resolved_model_profile(current["row"], registered_references=model_service_references(), + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + except ValueError: + pass + if (previous is not None and previous.outlet_id == record["model_id"] + and previous.identity.tenant_id == record["tenant_id"] + and previous.identity.request_model_id == name + and previous.identity.provider_id == (record.get("model_factory") or "custom")): + from services.model_runtime_refresh_service import refresh_saved_outlet + + profile = refresh_saved_outlet(record, previous, revision) + else: + capability = resolve_reasoning_capability(name, record.get("base_url"), record.get("model_factory")) + profile = build_legacy_outlet_snapshot( + {**record, "config_revision": revision}, legacy_capability=capability, + migrated_at=datetime.now(UTC).isoformat(), + ) + data = profile.model_dump() + data["provenance"]["provider_metadata"]["legacy_context_columns"] = { + key: record.get(key) for key in ( + "context_window_tokens", "max_input_tokens", "max_output_tokens", "default_output_reserve_tokens", + "tokenizer_family", "capacity_source", "capability_profile_version", + ) + } + profile = type(profile).model_validate(data) + encoded = encode_resolved_model_profile(profile, registered_references=model_service_references(), + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + envelope = {"version": 1, "revision": revision, "source_fingerprint": fingerprint, "row": encoded} + if len(json.dumps(envelope, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_RUNTIME_SNAPSHOT_BYTES: + raise ValueError("MODEL_OUTLET_SNAPSHOT_TOO_LARGE") + return envelope + except (ValueError, TypeError, KeyError): + # Preserve legacy management behavior; an unusable snapshot is never executable. + # Diagnostics contain categories only, never submitted values or credentials. + return {"version": 1, "revision": revision, "source_fingerprint": fingerprint, + "error": "MODEL_OUTLET_PROVISIONING_FAILED"} + + +def create_model_record(model_data, user_id, tenant_id): + """Provision the snapshot in the same management transaction as insertion.""" + from database.model_management_db import create_model_record as persist + + return persist(model_data, user_id, tenant_id, runtime_profile_builder=build_runtime_outlet_snapshot) + + +def update_model_record(model_id, update_data, user_id=None, tenant_id=None): + """Provision under the management row lock, retaining the legacy return contract.""" + from database.model_management_db import update_model_record as persist + + return persist(model_id, update_data, user_id, tenant_id, runtime_profile_builder=build_runtime_outlet_snapshot) diff --git a/backend/services/model_runtime_refresh_service.py b/backend/services/model_runtime_refresh_service.py new file mode 100644 index 0000000000..c0cfa92da1 --- /dev/null +++ b/backend/services/model_runtime_refresh_service.py @@ -0,0 +1,62 @@ +"""Update saved outlet values without reinterpreting the profile catalog.""" + +from types import SimpleNamespace +from urllib.parse import urlsplit + + +def refresh_saved_outlet(record, profile, revision): + """Preserve saved declarations and capabilities on legacy management edits.""" + from nexent.core.agents.agent_model import ModelConfig + from nexent.core.model_service.compatibility.legacy_config import ( + legacy_extra_body, + legacy_thinking_enabled, + ) + from nexent.core.model_service.registry import model_service_references + from services.model_runtime_options_service import compile_model_runtime_profile + + extra = record.get("extra_params") or {} + model = ModelConfig( + cite_name=record["display_name"], model_name=profile.identity.request_model_id, url=record["base_url"], + resolved_profile=profile, temperature=record.get("temperature"), top_p=record.get("top_p"), + max_tokens=record.get("max_tokens"), max_output_tokens=record.get("max_output_tokens"), + context_window_tokens=record.get("context_window_tokens"), + default_output_reserve_tokens=record.get("default_output_reserve_tokens"), + enable_thinking=legacy_thinking_enabled(extra), reasoning_effort=extra.get("reasoning_effort"), + reasoning_budget_tokens=extra.get("reasoning_budget_tokens"), extra_body=legacy_extra_body(extra), + ) + updated = compile_model_runtime_profile(model, SimpleNamespace(context_budget_snapshot=None, + requested_output_tokens=None)) + data = updated.model_dump() + data["config_revision"] = revision + data["identity"]["display_name"] = record["display_name"] + endpoint = urlsplit(record["base_url"]) + if (endpoint.scheme not in {"http", "https"} or not endpoint.hostname + or endpoint.username or endpoint.password or endpoint.fragment): + raise ValueError("MODEL_OUTLET_ENDPOINT_INVALID") + _ = endpoint.port + kind = "model_record" if record.get("api_key") else "none" + data["connection"].update( + base_url=record["base_url"], ssl_verify=record.get("ssl_verify") if record.get("ssl_verify") is not None else True, + timeout_seconds=record.get("timeout_seconds") or 60, concurrency_limit=record.get("concurrency_limit"), + credential_ref={"kind": kind, "model_id": profile.outlet_id}, + ) + data["connection_rules"]["credential_kind"] = kind + data["bindings"]["auth"] = next(ref.model_dump() for ref in model_service_references()["auth"] + if ref.ref == ("bearer_token_v1" if kind == "model_record" else "no_auth_v1")) + source_context = profile.provenance.provider_metadata.get("legacy_context_columns") + + def changed(column): + if isinstance(source_context, dict) and column in source_context: + return record.get(column) != source_context[column] + return record.get(column) is not None + + for field, column in (("combined_window", "context_window_tokens"), ("input_limit", "max_input_tokens"), + ("output_limit", "max_output_tokens")): + if changed(column): + data["capabilities"]["context"][field] = record.get(column) + for field, column in (("output_reserve_tokens", "default_output_reserve_tokens"), + ("tokenizer_family", "tokenizer_family"), ("capacity_source", "capacity_source"), + ("capability_profile_version", "capability_profile_version")): + if changed(column): + data["context_configuration"][field] = record.get(column) + return type(profile).model_validate(data) diff --git a/backend/services/model_runtime_snapshot_service.py b/backend/services/model_runtime_snapshot_service.py new file mode 100644 index 0000000000..4691c6ea2f --- /dev/null +++ b/backend/services/model_runtime_snapshot_service.py @@ -0,0 +1,44 @@ +"""Restore trusted saved outlets for runtime; never infer profiles from aliases.""" + +import json + + +def restore_runtime_model_snapshot(record, tenant_id, *, strict=True): + """Leave unmigrated records to legacy consumers; fail closed on corrupt saved profiles.""" + envelope = record.get("runtime_outlet_snapshot") + if envelope is None and record.get("profile_schema_version") is None: + return None + from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + ) + from nexent.core.model_service.registry import model_service_references + + try: + if record.get("tenant_id") != tenant_id: + raise ValueError("MODEL_OUTLET_TENANT_MISMATCH") + stored = record + if envelope is not None: + from services.model_runtime_provisioning_service import ( + MAX_RUNTIME_SNAPSHOT_BYTES, + model_source_fingerprint, + ) + + if (not isinstance(envelope, dict) or type(envelope.get("version")) is not int or envelope["version"] != 1 + or len(json.dumps(envelope, ensure_ascii=False, allow_nan=False).encode("utf-8")) + > MAX_RUNTIME_SNAPSHOT_BYTES + or envelope.get("source_fingerprint") != model_source_fingerprint(record)): + raise ValueError("MODEL_OUTLET_STALE_SNAPSHOT") + stored = envelope.get("row") + if not isinstance(stored, dict): + raise ValueError("MODEL_OUTLET_PROVISIONING_REQUIRED") + if (stored.get("model_id") != record.get("model_id") or stored.get("tenant_id") != tenant_id + or envelope.get("revision") != stored.get("config_revision")): + raise ValueError("MODEL_OUTLET_IDENTITY_MISMATCH") + return decode_resolved_model_profile(stored, registered_references=model_service_references(), + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + except (ValueError, TypeError, KeyError): + if strict: + raise + # Unselected/legacy consumers must not be broken by another outlet's bad snapshot. + # Jiuwen rejects a missing selected profile explicitly during preflight. + return None diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index e5084ffc89..0405b094bd 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -47,7 +47,6 @@ from consts.error_code import ErrorCode from consts.exceptions import AppException -from database.model_management_db import create_model_record from database.user_tenant_db import insert_user_tenant, get_user_tenant_by_user_id from database.oauth_account_db import list_oauth_accounts_by_user_id from database.group_db import query_group_ids_by_user @@ -62,6 +61,13 @@ logging.getLogger("user_management_service").setLevel(logging.INFO) +def create_model_record(*args, **kwargs): + """Provision trusted snapshots when a tenant receives its initial models.""" + from services.model_runtime_provisioning_service import create_model_record as persist + + return persist(*args, **kwargs) + + def get_provider_username(user_id: str, provider: str) -> Optional[str]: """Return a non-empty username stored for a linked authentication provider.""" try: diff --git a/backend/utils/agent_stream_utils.py b/backend/utils/agent_stream_utils.py index 09485d0244..6361e3ebea 100644 --- a/backend/utils/agent_stream_utils.py +++ b/backend/utils/agent_stream_utils.py @@ -20,6 +20,8 @@ from database.attachment_db import _build_mcp_presigned_url, get_file_url, upload_fileobj from services.file_management_service import is_allowed_skill_upload_path +from utils.runtime_output import is_runtime_event + logger = logging.getLogger(__name__) @@ -28,11 +30,16 @@ def finalize_buffered_unit_fragments(message_units: list[dict[str, Any]]) -> int finalized_bytes = 0 for unit in message_units: unit.pop("_attempt_id", None) + unit.pop("_block_id", None) fragments = unit.pop("_content_fragments", None) if fragments is not None: content = "".join(fragments) unit["content"] = content unit["unit_content"] = content + envelope = unit.pop("_runtime_envelope", None) + if envelope is not None: + envelope["content"] = unit["content"] + unit["content"] = unit["unit_content"] = json.dumps(envelope, ensure_ascii=False) finalized_bytes += len(str(unit.get("unit_content", "")).encode("utf-8")) return finalized_bytes @@ -62,6 +69,8 @@ def is_stream_unit_continuation( and current_unit.get("_attempt_id") == data.get("attempt_id") and current_unit.get("invocation_id") == data.get("invocation_id") and current_unit.get("_step_id") == data.get("step_id") + and current_unit.get("_block_id") == data.get("block_id") + and bool(current_unit.get("_runtime_envelope")) == is_runtime_event(data) ) @@ -103,6 +112,8 @@ def extract_skill_file_upload_payloads(content: str) -> list[dict]: def serialize_stream_unit_content(data: Dict[str, Any], content: Any) -> str: """Preserve structured content and tool metadata in the text unit column.""" + if is_runtime_event(data): + return json.dumps({**data, "content": content}, ensure_ascii=False) if data.get("type") == "human_interaction" and not isinstance(content, str): return json.dumps(content, ensure_ascii=False) if data.get("type") not in {"tool", "tool-call"}: diff --git a/backend/utils/runtime_output.py b/backend/utils/runtime_output.py new file mode 100644 index 0000000000..91b7fd6944 --- /dev/null +++ b/backend/utils/runtime_output.py @@ -0,0 +1,37 @@ +"""Validate the explicit root result without inferring success from stream EOF.""" + +from nexent.core.agent_runtime_support.output import RunResult + + +def is_runtime_event(event): + return (event.get("schema_version") == 1 and event.get("runtime_mode") == "native_agent" + and event.get("agent_core") == "jiuwen") + + +class RuntimeOutputState: + def __init__(self, required=False): + self.required = required + self.result = None + self.invalid = False + + def observe(self, event): + if not is_runtime_event(event) or event.get("invocation_id"): + return + self.required = True + if self.result is not None: + self.invalid = True + if event.get("type") == "run_terminal": + try: + self.result = RunResult.model_validate({key: event[key] for key in ( + "turn_id", "status", "end_reason", "final_content", "final_block_ids", "error", + ) if key in event}) + except ValueError: + self.invalid = True + + @property + def status(self): + return self.result.status if self.result is not None and not self.invalid else "failed" + + @property + def final_content(self): + return self.result.final_content if self.status == "completed" else "" diff --git a/deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql b/deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql new file mode 100644 index 0000000000..f3ef2e88d4 --- /dev/null +++ b/deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql @@ -0,0 +1,11 @@ +-- Migration kind: REQUIRED_SCHEMA +-- Required for: management-side trusted model snapshots consumed by optional Agent Cores. +-- This transitional envelope is decoded by the canonical ModelService storage codec. +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS runtime_outlet_snapshot JSONB; + +COMMENT ON COLUMN nexent.model_record_t.runtime_outlet_snapshot IS + 'Management-owned canonical outlet row plus source fingerprint; NULL requires explicit provisioning. Contains no credentials; shared profile codec validates the complete envelope, maximum 1 MiB.'; + +-- Backfill is explicit and application-owned: no SQL inference of capabilities. +-- Run backend/scripts/migrate_runtime_outlets.py after applying this migration. diff --git a/docs/agent-core-adapters.md b/docs/agent-core-adapters.md new file mode 100644 index 0000000000..1185d26bd6 --- /dev/null +++ b/docs/agent-core-adapters.md @@ -0,0 +1,108 @@ +# 可选 Agent Core:接入与验证说明 + +## 2026-10-16 当前接入方式(M6) + +Nexent 继续保存智能体配置,Factory 选择 Jiuwen Adapter,Jiuwen Runner 负责推理循环。模型统一经 ModelService 发送;原生工具调用和受控文本协议共用授权资源入口,不运行 CodeAgent 循环。 + +- 依赖固定 `openjiuwen==0.1.19`;SDK 安装 `nexent[jiuwen]`。公共模型服务集成至固定 6dc5ed0bb 基线的必要增量,不表示重新核验了 Jiuwen 最新发布。 +- 原生/text 均支持已授权工具、MCP、Skills 和显式 parallel;文本执行使用现有 Python/沙箱及远端回调上下文。MCP 的保存参数、隐藏身份、超时/会话所有权沿用 Nexent;Skills 按 root+name 白名单读取,脚本运行在配置指定的后端。 +- 页面切换 Core 只修改 core,保留工具、MCP、Skills、协作绑定、模型和所有策略值。Jiuwen 不支持的选项仍禁用编辑并在运行前明确拒绝;需要先在 smolagents 下关闭尚未支持的能力,再运行 Jiuwen。 +- 子智能体逐调用经工厂选择自身 Core,按自身版本绑定模型出口/参数;Jiuwen↔smolagents 和 Jiuwen→Jiuwen 已验证。A2A 复用现有代理,每调用独立客户端并传递可信身份,远端自行选择其 Core/模型。页面允许 Jiuwen 编辑协作绑定。 +- CM 复用现有 ContextManager,遵循保存开关与预算;配置页变更 Core 并按既有发布流程生效后,原会话可重开续聊,历史工具/附件作为只读事实,已有摘要仍可加载。仍未支持 Planning、运行总结、协议修复、校验/护栏及 provider prompt-cache 策略;公共 StepObservation/RunResult 已接入实时与历史展示,工具/结果、显式推理、子调用分组和失败/停止保留真实状态;终答仅由明确终态确认。保留绑定不表示已实现执行,不静默切回其他 Core。 +- 完整可信 `ResolvedModelProfile` 在管理侧保存,运行时恢复、验证并按请求预算生成独立快照;不按别名猜出口,不在运行时重新读 catalog,不走原生 HTTP。凭据只按 tenant/出口引用捕获,不进入模型资源视图。 + +M5 本地验证:515 项 Python UT、16 项前端组件、8 个正式 Case / 54 项检查通过。复用 SSE 与数据库执行单元,刷新后不按当前 Core 猜测旧输出;失败/停止保留过程,A2A 不虚构远端步骤。实际框架/资源/隔离数据库和受控 API 浏览器分别证明执行与展示,不等同于已部署的商业模型验收。证据见原 SPEC task.md 的 M5 历史记录;当时待实施的 AC-018 已在下述 M6 完成。 + +M6 已部署并通过选定真实出口:DeepSeek 原生工具协议、Qwen 受控文本协议,工具/MCP/Skills、两种子 Core 与 A2A 均完成实际执行桥验证。固定参数纠正每步骤 1 次/每轮 3 次(含已知 code 参数错误),空/仅推理/length 每轮 1 次;物理重试仍归 ModelService。原 Code 协议修复开关继续按 Jiuwen 能力校验。失败不切模型/Core,坏块不执行,历史任务不重放。 + +数据库在事务锁中分配用户/助手序号,空客户端历史也能从原会话续聊;私有运行边界不改变 HTTP schema。M6 862 项 Python UT、16 项前端组件和 10 个正式 Case / 72 项检查通过;真实模型与受控资源、浏览器受控 API 的边界详见原 SPEC task.md。应用访问 `http://localhost:3000`,镜像使用 `jiuwen-m6-20261016` 标签。全局正式资产门禁仍被既有 F-111-RULE-02 引用阻塞。 + +### 统一输出与原有会话页面(2026-10-16) + +不同Core输出在 `frontend/lib/nexentOutputAdapter.ts` 统一转换为原有Nexent事件,然后进入原有实时/历史解析器。页面使用同一套Reasoning、ToolFallback、Markdown和SubAgentContainer,不按Core选择渲染器;已移除M5的runtime-output专用路径。历史记录按自身保存的协议解析,智能体切换Core后仍可加载旧记录。 + +工具/MCP/Skills映射原工具卡,子智能体/A2A沿用原分组;只有封口后的成功终态生成终答,失败/停止保留partial。token用量绑定本条消息,未知显示未知,不借用其他轮计数。新Core需在执行端满足公共事实合同;其注册与配置校验仍独立适配。 + +本轮Web已部署 `nexent/nexent-web:jiuwen-unified-ui-20261016`,后端保持jiuwen-image-20261016。63项前端UT、TypeScript及定向lint通过,既有D3/D5回归和最终部署上的固定浏览器旅程通过;真实后端旧历史加载/刷新通过,未触发新Agent运行。标准prepared入口/全局资产门禁仍被既有F-111-RULE-02缺失引用阻塞,不声明完整发布门禁通过。完整证据见原SPEC新增UO记录。 + +### 共享容量展示补齐(2026-10-19) + +Jiuwen公共StepObservation.context_capacity现从已有ContextManager读取容量、阈值、有效输入上限和处理模式,经Observer及共用输出Adapter映射到原token_count。CM关闭仍保留容量,原用量百分比/详情组件无需Core分支。旧历史缺字段时保留未知,不用当前模型值回填。 + +main/Web已部署jiuwen-capacity-20261019。108项Python UT、43项前端UT及既有D3/D5回归通过;文件解析助手8轮真实业务验证覆盖两Core、CM开关和刷新,窗口/阈值与用量形式一致。正式D4仍因AIDP列表502为FAIL,prepared入口仍有F-111引用阻塞;详见原SPEC容量补齐记录,不宣称完整发布通过。 + +### 升级与存量出口回填 + +1. 核对现有 Core 字段迁移记录;已应用的 v2.7.2_1010 不重复执行,不改旧 SQL。 +2. 部署代码前应用新增 [模型快照迁移](../deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql)。它只加过渡 JSONB 字段,保留旧模型 API/列。 +3. 在配置好后端数据库连接的部署环境使用 Python 3.11 执行 `backend/scripts/migrate_runtime_outlets.py --tenant-id `,默认 dry run;确认结果后加 `--apply` 写入。失败项不能作为可运行的 Jiuwen 出口;重复 apply 对未变化档案幂等。 +4. 后续模型管理保存、健康检查和租户默认模型写入会在同一事务维护快照。普通修改按冻结声明校验、行锁合并和递增 revision。旁路修改旧列使档案陈旧时,Jiuwen 提前拒绝,需要通过管理保存或迁移修复。 + +M4 本地验证:753 项 Python UT、4 项前端组件、10 个正式 Case / 54 项测试通过;包含真实隔离 PostgreSQL 的双向配置 Core 变更、长历史摘要、CM 开关、故障/取消恢复和浏览器旧会话续聊。完整发布工作流及商业模型验证不在本轮证明范围;显式版本绑定不改写,未部署。证据见原 SPEC task.md 的 M4 记录。 + +上轮已在隔离 PostgreSQL 验证迁移、回填、并发和回滚。本轮完成 853 项 Python UT、4 项前端组件回归和 7 个正式 Case / 39 项检查,含混合 Core、子工具授权隔离、A2A、取消/超时/容量耗尽恢复与页面协作编辑。文本父子同时执行需要相应 sandbox worker 容量;协作排队和执行受有限总 deadline 控制。未修改现有应用数据库、未重建部署容器;具体证据与边界详见原 SPEC task.md。全局资产门禁仍被既有 F-111-RULE-02 引用阻塞;不声明全局通过或真实商业模型验收完成。 + +--- + +## 以下为首版历史说明(不作为当前启用操作) + +## 本次实现 + +Nexent 继续保存智能体、模型、工具、MCP 和 Skills 配置。执行时,`AgentCoreFactory` 根据 `agent_core` 创建 Adapter,由 Adapter 编译配置并执行所选 Core。 + +```text +Nexent 配置 → AgentCoreFactory → AgentCoreAdapter + ├─ SmolagentsAdapter → 现有执行链路 + └─ JiuwenAdapter → create_deep_agent → Runner.run_agent_streaming +``` + +Jiuwen 路径使用原生模型客户端、LocalFunction、MCPTool 和 SkillUseRail。Nexent 原有工具的业务函数及沙箱设施继续复用;Jiuwen 负责模型循环、工具选择和调用。不会在该路径创建 smolagents Agent。 + +## 如何启用 + +1. 先执行新增迁移 `deploy/sql/migrations/v2.6.0_1010_agent_core_factory.sql`。迁移为每个智能体版本添加 `agent_core`,存量数据默认 `smolagents`。 +2. 后端依赖固定为 `openjiuwen==0.1.18`;单独使用 SDK 时安装 `nexent[jiuwen]`。 +3. 通过现有 `POST /agent/update` 接口,在原请求中增加以下字段,并使用现有版本发布流程: + +```json +{ + "agent_core": "jiuwen", + "enable_context_manager": false, + "provide_run_summary": false, + "enable_planning": false, + "enable_protocol_repair_retry": false +} +``` + +以上为字段片段;请求仍需携带原接口要求的智能体标识等字段。模型需要支持 OpenAI 兼容的工具调用协议。Skills 使用 Nexent 解析后的授权快照,MCP 使用 Nexent 解析后的服务器地址及认证配置。 + +SDK 直接调用时设置 `AgentConfig(agent_core="jiuwen", ...)`。省略该字段或设置为 `smolagents`,使用原执行链路。当前没有新增前端选择控件。 + +## 当前能力与边界 + +| 能力 | 当前实现 | +| --- | --- | +| 本地、内置及现有业务工具 | 业务函数绑定为原生 Jiuwen LocalFunction | +| MCP | 原生 Jiuwen MCPTool;只暴露已配置且可见的工具,保留认证、用户上下文注入和超时控制 | +| Skills | 原生 Rail 加载授权目录;读取与脚本通过原生工具调用,沿用 Nexent 脚本执行及沙箱后端 | +| 并行工具 | 原生工具调用及授权工具列表的 parallel_executor | +| 上下文、历史与流式结果 | 编译授权业务上下文和历史;转换为现有 MessageObserver 事件 | +| 停止与资源回收 | 每次运行独立 owner/session,取消及清理只作用于当前运行 | +| 保存、版本及导入导出 | 保留 agent_core;旧配置默认 smolagents | + +首版明确拒绝:子智能体、外部 A2A、Nexent Planning/Verification/Guardrail、协议修复、运行总结、自适应上下文压缩、模型提供方 Prompt Cache 策略及非默认 smolagents 输出协议。检查会返回配置错误;不会自动回退到 smolagents。关闭上下文压缩后仍执行已解析的输入预算检查。 + +Skills 自动增加的宿主机 bash/read_file 能力已关闭。脚本实际运行位置沿用智能体的 Nexent 沙箱配置。同步业务函数无法被 Python 强制终止时,沿用 ThreadManager 的取消和 STUCK 标记,不能保证强制终止该函数。 + +## 扩展其他 Core + +继承 `AgentCoreAdapter`,实现 `capabilities()`、`validate()`、`run()`,按需实现 `prepare()` 和 `close()`,随后调用 `AgentCoreFactory.register("new_core", NewCoreAdapter)`。SDK 配置支持注册名;后端 API 当前仅开放 `smolagents` 和 `jiuwen`,接入新 Core 时同时扩展其配置校验。 + +## 验证结果 + +- 新增正式 D1/D2/D3/D5 用例共 6 个,覆盖工厂、配置、事件、API 边界、原生执行、取消、MCP 超时和运行隔离。 +- D3 真实调用 Jiuwen Runner、MCP 客户端、本地工具和 Skill 脚本;仅模型响应及 MCP 服务由确定性本地服务提供。 +- Legacy 回归:SDK 执行 45 个、SDK 配置 69 个、数据库操作 67 个、后端配置构建 254 个测试通过。 +- 外部模型服务、远端沙箱和实际数据库迁移尚未执行,不能据此宣称生产环境验收完成。 + +固定测试位于 `test/automation/{d1,d2,d3,d5}`;用例契约和实现绑定位于 `test/cases`、`test/manifests/agent-core-factory.yaml`。 diff --git a/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx b/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx index 4d5109542e..0f306c34fe 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/agent-core-selector.tsx @@ -38,7 +38,7 @@ export default function AgentCoreSelector() { id="agent-context-manager" aria-label={t("agent.core.contextManager")} checked={agent.enable_context_manager ?? true} - disabled={readOnly || core === "jiuwen"} + disabled={readOnly} onChange={(enable_context_manager) => update({ enable_context_manager }) } diff --git a/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx b/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx index b9ed43474f..49fcaa411b 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/collaborative-agent.tsx @@ -116,7 +116,7 @@ export function CollaborativeAgentActions({ const updateExternalSubAgentIds = useAgentStore( (state) => state.updateExternalSubAgentIds ); - const isReadOnly = useAgentReadOnly() || editedAgent?.agent_core === "jiuwen"; + const isReadOnly = useAgentReadOnly(); const { invalidate: invalidateExternalAgents } = useExternalAgents(); const externalSubAgentIdList = editedAgent?.external_sub_agent_id_list || []; @@ -218,7 +218,7 @@ export default function CollaborativeAgent({ const updateExternalSubAgentIds = useAgentStore( (state) => state.updateExternalSubAgentIds ); - const isReadOnly = useAgentReadOnly() || editedAgent?.agent_core === "jiuwen"; + const isReadOnly = useAgentReadOnly(); const { availableAgents: internalAgents } = usePublishedAgentList(); const { availableAgents: externalAgents } = useExternalAgents(); diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 2a04858952..67f0a11205 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -1,5 +1,8 @@ "use client"; +import { normalizeNexentHistory } from "@/lib/nexentOutputAdapter"; +import type { NexentOutputStatus } from "@/types/nexentOutput"; + import type { FC, MutableRefObject, PropsWithChildren } from "react"; import { useMemo } from "react"; import { @@ -44,6 +47,7 @@ import { createAssistantStream } from "assistant-stream"; import type { AttachmentType } from "../utils/attachment-type"; import { attachExecutionLogsToTool, + markSubAgentRunFinished, collapseSubAgentParts, attachSearchContentToTool, buildExecutionCodePart, @@ -382,7 +386,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { // Backend returns message as a string for user messages, but as an array of // ApiMessageItem for assistant messages. Normalize to array for consistent handling. - const messageParts = Array.isArray(msg.message) + const sourceParts = Array.isArray(msg.message) ? msg.message.map((part) => ({ ...part, content: @@ -393,6 +397,8 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { : typeof msg.message === "string" ? [{ type: "text", content: msg.message }] : []; + const normalized = normalizeNexentHistory(sourceParts, msg.status); + const messageParts = normalized.events; const runHasFinalAnswer = messageParts.some( (part) => part.type === "final_answer" ); @@ -459,6 +465,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { } const content: any[] = []; + let restoredRuntimeStatus: NexentOutputStatus | undefined; let historicalCardAction: Nl2AgentCardAction | null = null; let historicalNl2a: import("./remote-chat-model-adapter").Nl2aMessage | null = null; @@ -630,7 +637,8 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { return imagePart; }; - for (const [partIndex, part] of messageParts.entries()) { + restoredRuntimeStatus = normalized.status; + for (const [partIndex, part] of normalized.events.entries()) { if ( part.type === "model_attempt_control" && detail.workbench_config?.mode === "skill_create" @@ -925,6 +933,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { })(); flushReasoning(endInvocationId ?? part.invocation_id); let payload: { + status?: "completed" | "failed" | "stopped"; invocation_id?: string; } = {}; try { @@ -954,6 +963,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { }); closing.reasoningText = ""; } + markSubAgentRunFinished(content, closing.runId, payload.status); activeSubAgents.delete(invocationId); } else if (activeSubAgents.size > 0) { // No id supplied (very old payload): fall back to closing the @@ -1129,7 +1139,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { flushReasoning(part.invocation_id); if (part.content) { const meta = buildMetadata(part.invocation_id); - finalAnswers.apply({ ...part, content: unwrapFinalAnswer(part.content), answer_phase: "delta" }, meta); + finalAnswers.apply({ ...part, content: unwrapFinalAnswer(part.content), answer_phase: part.answer_phase ?? "delta" }, meta); } } } @@ -1316,7 +1326,8 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { ? { nl2agentCardAction: historicalCardAction } : {}), ...(historicalNl2a ? { nl2a: historicalNl2a } : {}), - ...(stepTokenCounts.length > 0 ? { stepTokenCounts } : {}), + // Empty saved usage must not fall back to another turn's live counters. + stepTokenCounts, ...(msg.role === "user" && typeof msg.message_index === "number" ? { historicalMessageIndex: msg.message_index } : {}), @@ -1328,6 +1339,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { id: messageId, role: msg.role, content, + ...(restoredRuntimeStatus ? {status: restoredRuntimeStatus} : {}), ...(createdAt ? { createdAt } : {}), ...(msg.role === "user" && attachments.length > 0 ? { attachments } diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 8c47486b0e..d605973d6e 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -13,6 +13,7 @@ import { createWorkbenchAgentDraft } from "@/features/workbench/agentCreationDra import { ApiError } from "@/services/api"; import { notifyWorkbenchConfigResolved } from "@/features/workbench/runtimeEvents"; import log from "@/lib/logger"; +import { adaptNexentOutputStream } from "@/lib/nexentOutputAdapter"; import { completeTrailingToolCalls } from "@/lib/toolCallStatus"; import { appendClarificationPart, @@ -63,6 +64,9 @@ interface SseChunk { unit_index?: number; // Unique ID shared by a tool call and its side-channel output. tool_call_id?: string; + provider_call_id?: string | null; + is_error?: boolean; + execution_status?: "completed" | "failed" | "stopped"; role?: string; tool_name?: string; tool_arguments?: string | Record; @@ -371,6 +375,7 @@ interface SubAgentStartPayload { } interface SubAgentEndPayload { + status?: "completed" | "failed" | "stopped"; agent_id?: number | string | null; agent_name?: string; invocation_id?: string; @@ -408,6 +413,8 @@ function parseSubAgentEnd(content: string): SubAgentEndPayload { export interface StepTokenCount { stepNumber: number; duration: number; + inputKnown?: boolean; + outputKnown?: boolean; stepInputTokens: number; stepOutputTokens: number; totalOutputTokens: number; @@ -440,7 +447,7 @@ export interface VerificationPanelPart { } // Accumulated total duration across all steps -let accumulatedDuration = 0; + /** * Parses a backend `token_count` payload into a `StepTokenCount` entry. @@ -461,6 +468,8 @@ export function parseStepTokenCount(content: string): StepTokenCount | null { return { stepNumber: data.step_number ?? 0, duration: data.duration ?? 0, + inputKnown: typeof data.step_input_tokens === "number", + outputKnown: typeof data.step_output_tokens === "number", stepInputTokens: data.step_input_tokens ?? 0, stepOutputTokens: data.step_output_tokens ?? 0, totalOutputTokens: data.total_output_tokens ?? 0, @@ -741,6 +750,7 @@ export function buildToolCallPart(chunk: SseChunk): any { argsText, unit_index: chunk.unit_index, tool_call_id: chunk.tool_call_id, + provider_call_id: chunk.provider_call_id, }; } @@ -945,6 +955,10 @@ export function attachExecutionLogsToTool( } targetToolCall.result = (targetToolCall.result ?? "") + chunk.content; + if (chunk.is_error) { + targetToolCall.isError = true; + targetToolCall.status = { type: "incomplete", reason: "error", error: chunk.content }; + } return true; } @@ -1212,10 +1226,11 @@ export function parseVerification(chunk: { * collapsible card flips from its running indicator to a finished state * without needing a separate per-message registry. */ -export function markSubAgentRunFinished(parts: any[], runId: string): void { +export function markSubAgentRunFinished(parts: any[], runId: string, executionStatus?: "completed" | "failed" | "stopped"): void { for (const part of parts) { if (part && part.metadata && part.metadata.runId === runId) { part.metadata.isRunning = false; + if (executionStatus) part.metadata.executionStatus = executionStatus; } } } @@ -1292,7 +1307,6 @@ export function pushStepTokenCount(step: StepTokenCount): void { */ export function clearStepTokenCounts(): void { stepTokenCounts.length = 0; - accumulatedDuration = 0; } /** @@ -1300,10 +1314,11 @@ export function clearStepTokenCounts(): void { /** * Parse and build timing metadata from backend token_count chunk. - * Also stores step data in the global registry for SingleTurnTokenUsage. + * Stores usage in the owning message and preserves the compatibility registry. */ function buildTimingFromTokenCount( - content: string + content: string, + runSteps: StepTokenCount[] ): ReturnType | null { const parsed = parseStepTokenCount(content); if (!parsed) { @@ -1311,16 +1326,9 @@ function buildTimingFromTokenCount( return null; } - // Store step data in global registry so the currently-streaming message's - // `SingleTurnTokenUsage` can render it without subscribing to per-message - // metadata updates. + runSteps.push(parsed); pushStepTokenCount(parsed); - - // Accumulate duration across all steps - accumulatedDuration += parsed.duration; - - // Use accumulated duration for total stream time - const totalDuration = accumulatedDuration; + const totalDuration = runSteps.reduce((total, step) => total + step.duration, 0); return buildTimingResult( Date.now(), // streamStartTime - approximate @@ -1758,7 +1766,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { return; } - const reader = agentResponse; + const output = adaptNexentOutputStream(agentResponse); + const reader = output.reader; const decoder = new TextDecoder(); let buffer = ""; @@ -1775,6 +1784,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { }; const invocationSlots = new Map(); const contentParts: any[] = []; + const runStepTokenCounts: StepTokenCount[] = []; const parentReasoning = createReasoningAccumulator(contentParts); const nl2SkillFilePartIndices = new Map(); let nl2SkillSummaryPartIndex: number | null = null; @@ -2290,7 +2300,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { : part ) ), - metadata: nl2a ? { custom: { nl2a } } : undefined, + ...(output.adapter.status ? { status: output.adapter.status } : {}), + metadata: { custom: { ...(nl2a ? { nl2a } : {}), stepTokenCounts: [...runStepTokenCounts] } }, }); const streamStartTime = Date.now(); @@ -2392,7 +2403,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Handle token_count - store timing for final yield if (chunk.type === "token_count") { - storedTiming = buildTimingFromTokenCount(chunk.content); + storedTiming = buildTimingFromTokenCount(chunk.content, runStepTokenCounts); continue; // Don't yield for internal data chunks } @@ -2637,7 +2648,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { flushOpenReasoning(closing.invocationId); } activeSubAgents.delete(invocationId!); - markSubAgentRunFinished(contentParts, closing.runId); + markSubAgentRunFinished(contentParts, closing.runId, payload.status); } // Pick the next active invocation id so subsequent reasoning / // tool parts default to a still-open sibling when present. @@ -3294,6 +3305,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { }); } + output.adapter.close(userAborted ? "stopped" : undefined); const finalResult = buildStreamResult( collapseSubAgentParts(contentParts) ); @@ -3316,6 +3328,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { content: "", }); } + // Cleanup must not replace an already reported transport failure. + await reader.cancel().catch(() => undefined); reader.releaseLock(); } }, diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 4550610bb9..0b76782f56 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -2209,6 +2209,7 @@ const SubAgentGroupRenderer: FC<{ const content = useAuiState((s) => s.message.content) as ReadonlyArray<{ type?: string; metadata?: { + executionStatus?: "completed" | "failed" | "stopped"; subagentId?: number | string; runId?: string; agentName?: string; @@ -2271,6 +2272,7 @@ const SubAgentGroupRenderer: FC<{ return ( content[index]?.metadata?.executionStatus).find(status => status === "failed" || status === "stopped")} agentName={descriptor.agentName} depth={descriptor.depth} isRunning={descriptor.isRunning} diff --git a/frontend/app/[locale]/newchat/ui/subagent.tsx b/frontend/app/[locale]/newchat/ui/subagent.tsx index de2da3980a..887dd8c406 100644 --- a/frontend/app/[locale]/newchat/ui/subagent.tsx +++ b/frontend/app/[locale]/newchat/ui/subagent.tsx @@ -1,11 +1,13 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { BotIcon, CheckIcon, ChevronDownIcon, LoaderIcon, + XCircleIcon, } from "lucide-react"; import { Collapsible, @@ -47,6 +49,7 @@ export interface SubAgentContainerProps { agentName: string; depth: number; isRunning?: boolean; + executionStatus?: "completed" | "failed" | "stopped"; task?: string; runId?: string; subagentId?: number | string; @@ -57,6 +60,7 @@ export const SubAgentContainer = ({ agentName, depth, isRunning, + executionStatus, task, runId, subagentId, @@ -64,6 +68,9 @@ export const SubAgentContainer = ({ }: SubAgentContainerProps) => { // Open by default while the sub-agent is still running so users can watch // the reasoning and tool calls unfold. + const { t } = useTranslation(); + const unsuccessful = + executionStatus === "failed" || executionStatus === "stopped"; const [open, setOpen] = useState(Boolean(isRunning)); const accent = accentForDepth(depth); @@ -73,20 +80,18 @@ export const SubAgentContainer = ({ onOpenChange={setOpen} className={cn( "my-3 ml-3 overflow-hidden rounded-lg border bg-card/80 shadow-sm", - accent.border, + accent.border )} data-subagent-depth={depth} data-subagent-run-id={runId} data-subagent-id={subagentId} > - +
@@ -95,16 +100,24 @@ export const SubAgentContainer = ({ {agentName} {isRunning ? ( + ) : unsuccessful ? ( + ) : ( )} - {isRunning ? "Running" : "Completed"} + {unsuccessful + ? t(`chat.executionStatus.${executionStatus}`) + : isRunning + ? "Running" + : "Completed"}
@@ -137,4 +150,4 @@ export const SubAgentContainer = ({ // ``SubAgentMessageParts`` renderer previously needed for the nested content // array has been removed; ``SubAgentContainer`` simply hosts the parts that // ``GroupedParts`` renders as its ``children``. -export {}; \ No newline at end of file +export {}; diff --git a/frontend/app/[locale]/newchat/ui/token-usage.tsx b/frontend/app/[locale]/newchat/ui/token-usage.tsx index 3da6d9e08b..fdd053ef78 100644 --- a/frontend/app/[locale]/newchat/ui/token-usage.tsx +++ b/frontend/app/[locale]/newchat/ui/token-usage.tsx @@ -4,10 +4,7 @@ import { useState, type FC } from "react"; import { useTranslation } from "react-i18next"; import { useAuiState, useMessageTiming } from "@assistant-ui/react"; import { Zap } from "lucide-react"; -import { - stepTokenCounts, - type StepTokenCount, -} from "../adapter/remote-chat-model-adapter"; +import type { StepTokenCount } from "../adapter/remote-chat-model-adapter"; interface TokenUsageProps { className?: string; @@ -36,14 +33,18 @@ export const TokenUsage: FC = ({ className }) => { > {usagePercent}% - {t("chat.tokenUsage.used")} + + {t("chat.tokenUsage.used")} + {/* Expanded details popover */} {expanded && (
- {t("chat.tokenUsage.details")} + + {t("chat.tokenUsage.details")} + {/* Expanded details popover */} @@ -211,9 +228,12 @@ export const SingleTurnTokenUsage: FC = ({ className {/* Stacked progress bar */}
- {t("chat.tokenUsage.context")} + + {t("chat.tokenUsage.context")} + - {totalTokensUsed.toLocaleString()} / {maxTokens.toLocaleString()} + {totalTokensUsed.toLocaleString()} /{" "} + {maxTokens.toLocaleString()}
@@ -230,12 +250,18 @@ export const SingleTurnTokenUsage: FC = ({ className style={{ width: `${Math.min(stepPercent, 100 - (index > 0 ? steps.slice(0, index).reduce((sum, s) => sum + ((s.stepInputTokens + s.stepOutputTokens) / maxTokens) * 100, 0) : 0))}%`, }} - title={t("chat.tokenUsage.stepSummary", { step: step.stepNumber, input: step.stepInputTokens, output: step.stepOutputTokens })} + title={t("chat.tokenUsage.stepSummary", { + step: step.stepNumber, + input: step.stepInputTokens, + output: step.stepOutputTokens, + })} > {/* Input portion (blue) */}
{/* Output portion (amber) */}
= ({ className
- {t("chat.tokenUsage.input")} + + {t("chat.tokenUsage.input")} +
- {t("chat.tokenUsage.output")} + + {t("chat.tokenUsage.output")} +
@@ -278,10 +308,13 @@ export const SingleTurnTokenUsage: FC = ({ className
- {t("chat.tokenUsage.total")} + + {t("chat.tokenUsage.total")} + - {totalTokensUsed.toLocaleString()} / {maxTokens.toLocaleString()} + {totalTokensUsed.toLocaleString()} /{" "} + {maxTokens.toLocaleString()}
diff --git a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx index 3ed794444a..48f61137ed 100644 --- a/frontend/app/[locale]/newchat/ui/tool-fallback.tsx +++ b/frontend/app/[locale]/newchat/ui/tool-fallback.tsx @@ -263,22 +263,31 @@ const ToolFallbackImpl = ({ argsText, result, status, + isError, searchContent, }: ToolCallMessagePartProps & { searchContent?: ToolSearchSource[]; }) => { + const displayStatus: ToolCallMessagePartStatus | undefined = isError + ? { + type: "incomplete", + reason: "error", + error: typeof result === "string" ? result : "Tool execution failed", + } + : status; const isCancelled = - status?.type === "incomplete" && status.reason === "cancelled"; + displayStatus?.type === "incomplete" && + displayStatus.reason === "cancelled"; return ( - + {!isCancelled && ( - + )} diff --git a/frontend/lib/agentCore.ts b/frontend/lib/agentCore.ts index a2f533522e..0b8d07d987 100644 --- a/frontend/lib/agentCore.ts +++ b/frontend/lib/agentCore.ts @@ -1,33 +1,9 @@ -import type { Agent, AgentCore } from "@/types/agentConfig"; -import { DEFAULT_AGENT_VERIFICATION_CONFIG } from "@/types/agentConfig"; +import type { Agent } from "@/types/agentConfig"; -/** Apply native Jiuwen limitations without changing supported resources. */ +/** Core selection never rewrites saved policies or resource bindings. */ export function compatibleCorePatch>( - current: Partial | null, + _current: Partial | null, patch: T ): T & Partial { - const core: AgentCore = - patch.agent_core ?? current?.agent_core ?? "smolagents"; - if (core !== "jiuwen") return patch; - const verification = - patch.verification_config ?? current?.verification_config; - return { - ...patch, - enable_context_manager: false, - provide_run_summary: false, - enable_protocol_repair_retry: false, - verification_config: { - ...DEFAULT_AGENT_VERIFICATION_CONFIG, - ...verification, - enabled: false, - guardrail_config: { - ...DEFAULT_AGENT_VERIFICATION_CONFIG.guardrail_config!, - ...verification?.guardrail_config, - enabled: false, - }, - }, - sub_agent_id_list: [], - sub_agent_relations: [], - external_sub_agent_id_list: [], - }; + return patch; } diff --git a/frontend/lib/nexentOutputAdapter.ts b/frontend/lib/nexentOutputAdapter.ts new file mode 100644 index 0000000000..e1a19ffd99 --- /dev/null +++ b/frontend/lib/nexentOutputAdapter.ts @@ -0,0 +1,436 @@ +import type { + NexentOutputEvent as Event, + NexentOutputStatus, + NexentRunStatus, +} from "@/types/nexentOutput"; + +interface Run { + source: Event; + sequence: number; + blocks: Map; + calls: Map; + results: Set; + result?: Event; + invalid?: string; + emitted?: boolean; + step: string; +} + +function object(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function envelope(event: Event): Event { + try { + const nested: unknown = JSON.parse(event.content); + if ( + object(nested) && + nested.schema_version === 1 && + nested.runtime_mode === "native_agent" && + nested.type === event.type && + typeof nested.content === "string" && + typeof nested.turn_id === "string" + ) + return { + ...event, + ...nested, + unit_index: event.unit_index ?? nested.unit_index, + } as Event; + } catch { + /* Ordinary text is already a Nexent event. */ + } + return event; +} + +function native(event: Event) { + return ( + event.schema_version === 1 && + event.runtime_mode === "native_agent" && + typeof event.turn_id === "string" + ); +} + +export function outputStatus( + status?: string, + error?: string +): NexentOutputStatus | undefined { + if (!status) return undefined; + if (status === "running" || status === "streaming") + return { type: "running" }; + if (status === "completed") return { type: "complete", reason: "stop" }; + return { + type: "incomplete", + reason: status === "stopped" ? "cancelled" : "error", + ...(error && { error }), + }; +} + +/** Normalize public run facts into existing Nexent events, never UI parts or Core-specific views. */ +export function createNexentOutputAdapter() { + const runs = new Map(); + const byInvocation = new Map(); + const duplicates = new Set(); + let root: Run | undefined; + let closed = false; + const duplicateKey = (e: Event) => + JSON.stringify([e.invocation_id ?? "", e.type, e.content]); + const converted = ( + e: Event, + type: string, + fields: Partial = {} + ): Event => ({ ...e, type, ...fields }); + const failure = (run: Run, reason: string) => { + run.invalid = reason; + }; + const resultStatus = (run: Run): NexentRunStatus => + run.invalid + ? "failed" + : ((run.result?.status as NexentRunStatus) ?? "failed"); + const complete = (run: Run): Event[] => { + if (run.emitted) return []; + run.emitted = true; + const result = run.result; + const status = resultStatus(run); + const source = result ?? run.source; + if (status === "completed") { + const ids = result?.final_block_ids as string[]; + return [ + converted(source, "final_answer", { + content: String(result?.final_content ?? ""), + answer_phase: "complete", + reclassify_step: true, + step_id: run.blocks.get(ids?.[0]) ?? source.step_id ?? run.step, + }), + ]; + } + if (status === "stopped") return []; + const error = object(result?.error) ? result.error : {}; + return [ + converted(source, "error", { + content: + run.invalid ?? + String( + error.safe_message ?? + result?.end_reason ?? + "NEXENT_TERMINAL_MISSING" + ), + }), + ]; + }; + const push = (raw: Event): Event[] => { + const e = raw; + if (!native(e)) { + const run = byInvocation.get(e.invocation_id ?? ""); + if ( + run && + (e.type === "tool" || e.type === "tool-call") && + e.tool_call_id + ) { + const key = JSON.stringify([ + run.source.turn_id, + e.invocation_id ?? "", + e.tool_call_id, + ]); + run.calls.set(e.tool_call_id, key); + return [converted(e, e.type, { tool_call_id: key })]; + } + if ( + run && + (e.type === "final_answer" || + (e.type === "token_count" && !e.content.includes('"step_number"'))) + ) + return []; + if (e.type === "execution_logs" && duplicates.has(duplicateKey(e))) + return []; + if (e.type === "subagent_end") { + let payload: Record = {}; + try { + payload = JSON.parse(e.content); + } catch { + /* Keep older boundaries unchanged. */ + } + const child = byInvocation.get( + String(payload.invocation_id ?? e.invocation_id ?? "") + ); + if (child) + return [ + ...complete(child), + converted(e, e.type, { + content: JSON.stringify({ + ...payload, + status: resultStatus(child), + }), + }), + ]; + } + if (run && e.tool_call_id && run.calls.has(e.tool_call_id)) + return [ + converted(e, e.type, { tool_call_id: run.calls.get(e.tool_call_id) }), + ]; + return [e]; + } + const id = String(e.turn_id); + let run = runs.get(id); + if (!run) { + run = { + source: e, + sequence: 0, + blocks: new Map(), + calls: new Map(), + results: new Set(), + step: e.step_id ?? id, + }; + runs.set(id, run); + byInvocation.set(e.invocation_id ?? "", run); + if (!e.invocation_id) root = run; + } + if (closed || run.result || run.emitted) { + failure(run, "NEXENT_OUTPUT_AFTER_TERMINAL"); + return []; + } + if ( + typeof e.sequence !== "number" || + !Number.isInteger(e.sequence) || + e.sequence <= run.sequence + ) { + failure(run, "NEXENT_OUTPUT_ORDER_INVALID"); + return []; + } + run.sequence = e.sequence; + if (e.type === "step_count") { + run.step = e.step_id ?? `${id}:step:${e.content}`; + return [converted(e, e.type, { step_id: run.step })]; + } + if (e.type === "model_output" || e.type === "model_output_deep_thinking") { + const block = String(e.block_id ?? e.step_id ?? run.step); + const first = !run.blocks.has(block); + const step = run.blocks.get(block) ?? e.step_id ?? block; + run.blocks.set(block, step); + const boundary = + first && run.step !== step + ? [converted(e, "step_count", { content: "", step_id: step })] + : []; + run.step = step; + return [ + ...boundary, + converted( + e, + e.type === "model_output" ? "model_output_thinking" : e.type, + { step_id: step, origin_type: e.type } + ), + ]; + } + if (e.type === "tool") { + const local = String(e.local_call_id ?? e.tool_call_id ?? ""); + if (!local || run.calls.has(local)) { + failure(run, "NEXENT_TOOL_ID_INVALID"); + return []; + } + const key = JSON.stringify([id, e.invocation_id ?? "", local]); + run.calls.set(local, key); + return [converted(e, "tool", { tool_call_id: key })]; + } + if (e.type === "tool_result") { + const key = run.calls.get( + String(e.local_call_id ?? e.tool_call_id ?? "") + ); + if (!key || run.results.has(key)) { + failure(run, "NEXENT_TOOL_RESULT_UNPAIRED"); + return []; + } + run.results.add(key); + const output = converted(e, "execution_logs", { + content: String(e.result ?? e.content), + tool_call_id: key, + }); + duplicates.add(duplicateKey(output)); + return [output]; + } + if (e.type === "step_observation") { + const usage = object(e.usage) ? e.usage : {}; + const capacity = object(e.context_capacity) ? e.context_capacity : {}; + const warning = object(e.step_error) + ? [ + converted(e, "warning", { + content: String( + e.step_error.safe_message ?? + e.step_error.category ?? + "Model step failed" + ), + }), + ] + : []; + return [ + ...warning, + converted(e, "token_count", { + content: JSON.stringify({ + step_number: e.step_number, + duration: e.duration, + step_input_tokens: usage.input_tokens ?? null, + step_output_tokens: usage.output_tokens ?? null, + total_output_tokens: usage.output_tokens ?? null, + context_window_tokens: capacity.context_window_tokens ?? null, + token_threshold: capacity.token_threshold ?? null, + effective_input_limit_tokens: capacity.effective_input_limit_tokens ?? null, + context_processing_mode: capacity.context_processing_mode ?? null, + }), + }), + ]; + } + if (e.type === "run_terminal") { + if ( + !["completed", "failed", "stopped"].includes(String(e.status)) || + (e.status !== "completed" && + (e.final_content || + (Array.isArray(e.final_block_ids) && e.final_block_ids.length))) || + (e.status === "completed" && + (e.error || + typeof e.final_content !== "string" || + !Array.isArray(e.final_block_ids))) + ) + failure(run, "NEXENT_TERMINAL_INVALID"); + run.result = e; + return []; + } + return [e]; + }; + return { + push, + close(persistedStatus?: string) { + if (closed) return []; + closed = true; + for (const run of runs.values()) { + if (!run.result) failure(run, "NEXENT_TERMINAL_MISSING"); + if ( + run === root && + persistedStatus === "failed" && + resultStatus(run) !== "failed" + ) + failure(run, "NEXENT_PERSISTED_RUN_FAILED"); + if (run === root && persistedStatus === "stopped") { + run.invalid = undefined; + run.result = { + ...run.source, + status: "stopped", + type: "run_terminal", + content: "", + }; + } + } + return [...runs.values()].flatMap(complete); + }, + get status() { + return root + ? outputStatus( + closed ? resultStatus(root) : "running", + root.invalid ?? + (object(root.result?.error) + ? String(root.result.error.safe_message ?? "") + : undefined) + ) + : undefined; + }, + }; +} + +/** Decode both persisted envelopes and original units without consulting the Agent's current Core. */ +export function normalizeNexentHistory(events: Event[], status?: string) { + const adapter = createNexentOutputAdapter(); + const normalized = [ + ...events.map(envelope).flatMap(adapter.push), + ...adapter.close(status), + ]; + const providers = new Map( + normalized + .filter((e) => e.type === "execution_logs" && e.provider_call_id) + .map((e) => [e.tool_call_id, e.provider_call_id]) + ); + for (const event of normalized) + if (event.type === "tool" && !event.provider_call_id) + event.provider_call_id = providers.get(event.tool_call_id); + const finals = new Set( + normalized + .filter((e) => e.type === "final_answer" && e.reclassify_step) + .map((e) => JSON.stringify([e.invocation_id ?? "", e.step_id])) + ); + return { + events: normalized.filter( + (e) => + !( + e.origin_type === "model_output" && + finals.has(JSON.stringify([e.invocation_id ?? "", e.step_id])) + ) + ), + status: adapter.status ?? outputStatus(status), + }; +} + +/** Wrap only the transport; all normalized bytes enter the original SSE parser. */ +export function adaptNexentOutputStream( + source: ReadableStreamDefaultReader +) { + const adapter = createNexentOutputAdapter(); + const decoder = new TextDecoder(), + encoder = new TextEncoder(); + let buffer = "", + finished = false; + const convert = (line: string) => { + if (!line.startsWith("data:")) return line + "\n"; + try { + const raw: unknown = JSON.parse(line.slice(5).trim()); + if ( + !object(raw) || + typeof raw.type !== "string" || + typeof raw.content !== "string" + ) + return line + "\n"; + return adapter + .push(raw as Event) + .map((e) => `data: ${JSON.stringify(e)}\n\n`) + .join(""); + } catch { + return line + "\n"; + } + }; + const stream = new ReadableStream({ + async pull(controller) { + try { + while (!finished) { + const { value, done } = await source.read(); + if (finished) return; + buffer += decoder.decode(value, { stream: !done }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + let output = lines.map(convert).join(""); + if (done) { + finished = true; + if (buffer) output += convert(buffer); + output += adapter + .close() + .map((e) => `data: ${JSON.stringify(e)}\n\n`) + .join(""); + source.releaseLock(); + } + if (output) controller.enqueue(encoder.encode(output)); + if (finished) controller.close(); + if (output || finished) return; + } + } catch (error) { + finished = true; + adapter.close("failed"); + source.releaseLock(); + controller.error(error); + } + }, + async cancel(reason) { + finished = true; + adapter.close("stopped"); + try { + await source.cancel(reason); + } finally { + source.releaseLock(); + } + }, + }); + return { reader: stream.getReader(), adapter }; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 61be6e42fd..6a1357a685 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -284,6 +284,9 @@ "chat.attachments.preview": "Preview {{name}}", "chat.attachments.remove": "Remove {{name}}", "chat.attachments.download": "Download", + "chat.executionStatus.failed": "Execution failed", + "chat.executionStatus.stopped": "Stopped", + "chat.tokenUsage.unknown": "Unknown", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", "chat.tokenUsage.details": "Token usage details", @@ -5779,5 +5782,13 @@ "modelParams.token.outputReserveInfo": "Output space reserved within the context window", "agent.core.label": "Agent Core", "agent.core.contextManager": "Automatic context compaction", - "agent.core.jiuwenHint": "Jiuwen does not yet support planning, automatic context compaction, run summaries, protocol repair, verification, guardrails or collaborative agents. Switching disables these options and clears collaboration bindings while keeping models, prompts, tools and skills. Configure them again manually after switching back to smolagents." + "agent.core.jiuwenHint": "Jiuwen supports tools, MCP, Skills, child agents and A2A collaboration. Switching Core preserves every setting. Context management follows the saved CM configuration. Planning, summaries, protocol repair, verification and guardrails are not supported yet. Disable these capabilities under smolagents before running Jiuwen; all bindings remain when switching back.", + "chat.runtimeOutput.process": "Execution process", + "chat.runtimeOutput.reasoning": "Reasoning", + "chat.runtimeOutput.failed": "Execution failed", + "chat.runtimeOutput.stopped": "Stopped", + "chat.runtimeOutput.subagent": "Sub-agent", + "chat.runtimeOutput.unknown": "Unknown", + "chat.runtimeOutput.step": "Step {{step}} · {{duration}} s · ", + "chat.runtimeOutput.usage": "Input {{input}} / output {{output}}" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 952854169e..db82fe1596 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -284,6 +284,9 @@ "chat.attachments.preview": "预览 {{name}}", "chat.attachments.remove": "移除 {{name}}", "chat.attachments.download": "下载", + "chat.executionStatus.failed": "执行失败", + "chat.executionStatus.stopped": "已停止", + "chat.tokenUsage.unknown": "未知", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", "chat.tokenUsage.details": "Token 使用详情", @@ -5793,5 +5796,13 @@ "modelParams.token.outputReserveInfo": "从上下文窗口中预留的输出空间", "agent.core.label": "Agent Core", "agent.core.contextManager": "上下文自动压缩", - "agent.core.jiuwenHint": "Jiuwen 暂不支持规划、上下文自动压缩、运行总结、协议修复、自校验、护栏及协作智能体。切换时会关闭这些选项并清空协作绑定,保留模型、提示词、工具和 Skills;切回 smolagents 后需手动重新配置。" + "agent.core.jiuwenHint": "Jiuwen 支持工具、MCP、Skills、子智能体和 A2A 协作。切换 Core 会保留所有配置;上下文管理遵循保存的 CM 配置。暂不支持规划、运行总结、协议修复、自校验和护栏。请先在 smolagents 下关闭这些能力后再运行 Jiuwen;切回后原绑定仍然保留。", + "chat.runtimeOutput.process": "执行过程", + "chat.runtimeOutput.reasoning": "思考", + "chat.runtimeOutput.failed": "执行失败", + "chat.runtimeOutput.stopped": "已停止", + "chat.runtimeOutput.subagent": "子智能体", + "chat.runtimeOutput.unknown": "未知", + "chat.runtimeOutput.step": "步骤 {{step}} · {{duration}} 秒 · ", + "chat.runtimeOutput.usage": "输入 {{input}} / 输出 {{output}}" } diff --git a/frontend/tests/component/TokenUsage.test.tsx b/frontend/tests/component/TokenUsage.test.tsx new file mode 100644 index 0000000000..c04ce4d268 --- /dev/null +++ b/frontend/tests/component/TokenUsage.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +const state = vi.hoisted(() => ({ steps: [] as unknown[] | undefined })); +vi.mock("@assistant-ui/react", () => ({ + useAuiState: () => state.steps, + useMessageTiming: () => null, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => (key === "chat.tokenUsage.unknown" ? "Unknown" : key), + }), +})); +import { SingleTurnTokenUsage } from "../../app/[locale]/newchat/ui/token-usage"; +import { + stepTokenCounts, + type StepTokenCount, +} from "../../app/[locale]/newchat/adapter/remote-chat-model-adapter"; +afterEach(() => { + stepTokenCounts.length = 0; +}); +beforeEach(() => { + state.steps = [ + { + stepNumber: 1, + duration: 1, + stepInputTokens: 0, + stepOutputTokens: 0, + totalOutputTokens: 0, + estimatedContextTokens: 0, + tokenThreshold: null, + contextWindowTokens: null, + inputKnown: false, + outputKnown: false, + }, + ]; +}); +it("shows unknown usage with the common component, without inventing zero", () => { + render(); + expect(screen.getByText(/input Unknown \/.*output Unknown/)).toBeVisible(); + expect(screen.queryByText("0%")).toBeNull(); +}); +it("keeps an explicit zero distinguishable from unknown", () => { + (state.steps![0] as Record).outputKnown = true; + render(); + expect(screen.getByText(/input Unknown \/.*output 0/)).toBeVisible(); +}); + +it("renders no counters for an explicitly empty saved turn", () => { + state.steps = []; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it("never borrows another turn's global usage when metadata is missing", () => { + stepTokenCounts.push(state.steps![0] as StepTokenCount); + state.steps = undefined; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it.each(["passthrough", "adaptive_compact"])( + "uses the original percentage component with known capacity in %s", + (mode) => { + Object.assign(state.steps![0] as Record, { + contextWindowTokens: 65536, + tokenThreshold: 24000, + stepInputTokens: 655, + stepOutputTokens: 0, + inputKnown: true, + outputKnown: true, + contextProcessingMode: mode, + }); + const { container } = render(); + expect(screen.getByRole("button")).toHaveTextContent("1%"); + expect(container.querySelector('[data-token-usage="summary"]')).toBeNull(); + } +); diff --git a/frontend/tests/component/ToolErrorStatus.test.tsx b/frontend/tests/component/ToolErrorStatus.test.tsx new file mode 100644 index 0000000000..658dd14237 --- /dev/null +++ b/frontend/tests/component/ToolErrorStatus.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +vi.mock("../../app/[locale]/newchat/ui/sources-panel-context", () => ({ + useSourcesPanel: () => ({ setSources: vi.fn(), setOpen: vi.fn() }), +})); +import { ToolFallback } from "../../app/[locale]/newchat/ui/tool-fallback"; +it.each([true, false])( + "uses the common tool card for isError=%s even after a completed run", + (isError) => { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Used tool: mcp" })); + expect(screen.getAllByText("Exact result").length).toBeGreaterThan(0); + if (isError) expect(screen.getByText("Error:")).toBeVisible(); + else expect(screen.queryByText("Error:")).toBeNull(); + } +); diff --git a/frontend/tests/component/agentCore.test.tsx b/frontend/tests/component/agentCore.test.tsx new file mode 100644 index 0000000000..2d3e2f8843 --- /dev/null +++ b/frontend/tests/component/agentCore.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { compatibleCorePatch } from "@/lib/agentCore"; +import { useAgentStore } from "@/stores/agentStore"; +import { updateAgentInfo } from "@/services/agentConfigService"; +import type { Agent } from "@/types/agentConfig"; + +vi.mock("@/services/agentConfigService", () => ({ + updateAgentInfo: vi.fn(), + updateToolConfig: vi.fn(), + searchToolConfig: vi.fn(), +})); + +const agent: Agent = { + id: "42", + name: "test", + model: "model", + description: "unchanged", + max_step: 10, + provide_run_summary: true, + enable_context_manager: true, + permission: "EDIT", + tools: [], + skills: [], + sub_agent_id_list: [8], + sub_agent_relations: [{ agent_id: 8, version_no: 1 }], + external_sub_agent_id_list: [9], +}; + +describe("Core configuration", () => { + beforeEach(() => { + vi.mocked(updateAgentInfo).mockResolvedValue({ + success: true, + data: { agent_id: 42 }, + message: "", + error: undefined, + }); + useAgentStore.getState().initialize(agent); + }); + + it("preserves all settings through Jiuwen saves and a return to smolagents", async () => { + useAgentStore.getState().updateAgentConfig({ agent_core: "jiuwen" }); + expect(await useAgentStore.getState().waitForIdle()).toBe(true); + expect(updateAgentInfo).toHaveBeenCalledWith({ + agent_id: 42, + agent_core: "jiuwen", + }); + expect(useAgentStore.getState().editedAgent).toMatchObject({ + enable_context_manager: true, + provide_run_summary: true, + sub_agent_id_list: [8], + sub_agent_relations: [{ agent_id: 8, version_no: 1 }], + external_sub_agent_id_list: [9], + }); + expect(useAgentStore.getState().editedAgent).toMatchObject({ + model: "model", + description: "unchanged", + tools: [], + skills: [], + }); + useAgentStore + .getState() + .updateAgentConfig({ enable_context_manager: true }); + await useAgentStore.getState().waitForIdle(); + expect(useAgentStore.getState().editedAgent?.enable_context_manager).toBe( + true + ); + useAgentStore.getState().updateAgentConfig({ + agent_core: "smolagents", + enable_context_manager: true, + }); + await useAgentStore.getState().waitForIdle(); + expect(useAgentStore.getState().editedAgent).toMatchObject({ + agent_core: "smolagents", + enable_context_manager: true, + sub_agent_id_list: [8], + }); + }); + + it("defaults legacy records and never mutates readonly configuration", async () => { + useAgentStore.getState().initialize({ ...agent, permission: "READ_ONLY" }); + useAgentStore.getState().updateAgentConfig({ agent_core: "jiuwen" }); + await useAgentStore.getState().waitForIdle(); + expect(useAgentStore.getState().editedAgent?.agent_core).toBe("smolagents"); + expect(updateAgentInfo).not.toHaveBeenCalled(); + }); + + it("rolls back a failed Core save and permits retry", async () => { + vi.mocked(updateAgentInfo).mockResolvedValueOnce({ + success: false, + data: null, + message: "rejected", + error: undefined, + }); + useAgentStore.getState().updateAgentConfig({ agent_core: "jiuwen" }); + expect(await useAgentStore.getState().waitForIdle()).toBe(false); + expect(useAgentStore.getState().editedAgent?.agent_core).toBe("smolagents"); + useAgentStore.getState().updateAgentConfig({ agent_core: "jiuwen" }); + expect(await useAgentStore.getState().waitForIdle()).toBe(true); + }); + + it("retains enabled guardrail and all its rules without changing caller input", () => { + const rules = [ + { name: "rule", pattern: "secret", severity: "block" as const }, + ]; + const patch = { + verification_config: { + enabled: true, + step_verification_enabled: true, + final_verification_enabled: true, + max_final_rounds: 2, + strictness: "balanced" as const, + fail_policy: "warn" as const, + critical_events: [], + guardrail_config: { + enabled: true, + rules, + default_action: "pass" as const, + }, + }, + }; + const result = compatibleCorePatch({ agent_core: "jiuwen" }, patch); + expect(result.verification_config?.guardrail_config).toMatchObject({ + enabled: true, + rules, + }); + expect(patch.verification_config.guardrail_config.enabled).toBe(true); + expect(compatibleCorePatch(null, { description: "same" })).toEqual({ + description: "same", + }); + }); +}); diff --git a/frontend/tests/component/contextCapacityOutput.test.tsx b/frontend/tests/component/contextCapacityOutput.test.tsx new file mode 100644 index 0000000000..59c8722ea5 --- /dev/null +++ b/frontend/tests/component/contextCapacityOutput.test.tsx @@ -0,0 +1,60 @@ +import { expect, it } from "vitest"; +import { + createNexentOutputAdapter, + normalizeNexentHistory, +} from "@/lib/nexentOutputAdapter"; + +const capacity = { + context_window_tokens: 65536, + token_threshold: 24000, + effective_input_limit_tokens: 32000, + context_processing_mode: "passthrough", +}; +const event = { + type: "step_observation", + content: "", + schema_version: 1, + runtime_mode: "native_agent", + turn_id: "turn", + sequence: 1, + step_number: 1, + duration: 0.2, + context_capacity: capacity, + usage: { input_tokens: 24, output_tokens: 3 }, +}; + +it.each(["passthrough", "adaptive_compact"])( + "maps shared capacity in live and saved %s output", + (mode) => { + const e = { + ...event, + context_capacity: { ...capacity, context_processing_mode: mode }, + }; + const live = createNexentOutputAdapter() + .push(e) + .find((e) => e.type === "token_count")!; + const saved = normalizeNexentHistory([ + { type: e.type, content: JSON.stringify(e) }, + ]).events.find((e) => e.type === "token_count")!; + const expected = { + ...capacity, + context_processing_mode: mode, + step_input_tokens: 24, + step_output_tokens: 3, + }; + expect(JSON.parse(live.content)).toMatchObject(expected); + expect(JSON.parse(saved.content)).toMatchObject(expected); + } +); + +it("keeps legacy missing capacity unknown instead of inventing another run's values", () => { + const adapter = createNexentOutputAdapter(); + adapter.push(event); + const e = adapter + .push({ ...event, turn_id: "other", context_capacity: undefined }) + .find((e) => e.type === "token_count")!; + expect(JSON.parse(e.content)).toMatchObject({ + context_window_tokens: null, + token_threshold: null, + }); +}); diff --git a/frontend/tests/component/runtimeOutput.test.tsx b/frontend/tests/component/runtimeOutput.test.tsx new file mode 100644 index 0000000000..7233cfecab --- /dev/null +++ b/frontend/tests/component/runtimeOutput.test.tsx @@ -0,0 +1,398 @@ +import { describe, expect, it } from "vitest"; +import { ReadableStream } from "node:stream/web"; +import { + createNexentOutputAdapter, + normalizeNexentHistory, + adaptNexentOutputStream, +} from "@/lib/nexentOutputAdapter"; +import type { NexentOutputEvent as Event } from "@/types/nexentOutput"; + +const event = ( + type: string, + sequence: number, + extra: Partial = {} +): Event => ({ + type, + content: "", + schema_version: 1, + runtime_mode: "native_agent", + agent_core: "jiuwen", + turn_id: "root", + sequence, + ...extra, +}); +const terminal = (sequence: number, extra: Partial = {}) => + event("run_terminal", sequence, { + status: "completed", + final_content: "Final", + final_block_ids: ["final"], + ...extra, + }); +const body = () => + event("model_output", 1, { block_id: "final", content: "Final" }); +const saved = (events: Event[]) => + events.map((e, unit_index) => ({ + type: e.type, + content: JSON.stringify(e), + unit_index, + })); + +async function bytes(events: Event[], size = 3, newline = true) { + const raw = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""); + const input = new TextEncoder().encode(newline ? raw : raw.trimEnd()); + const source = new ReadableStream({ + start(c) { + for (let i = 0; i < input.length; i += size) + c.enqueue(input.slice(i, i + size)); + c.close(); + }, + }); + const { reader, adapter } = adaptNexentOutputStream( + source.getReader() as ReadableStreamDefaultReader + ); + let text = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + text += new TextDecoder().decode(value); + } + return { + events: text + .split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => JSON.parse(l.slice(5))), + status: adapter.status, + }; +} + +describe("Nexent output boundary, shared by all Core renderers", () => { + it("maps process, reasoning, tools and exact results to original event types", () => { + const adapter = createNexentOutputAdapter(); + const out = [ + event("model_output", 1, { block_id: "one", content: "Before" }), + event("model_output_deep_thinking", 2, { + block_id: "one", + content: "Explicit reasoning", + }), + event("tool", 3, { + local_call_id: "local", + provider_call_id: "provider-" + "x".repeat(300), + tool_name: "mcp", + tool_arguments: { a: 1 }, + }), + event("tool_result", 4, { + local_call_id: "local", + result: "Exact result", + is_error: true, + }), + ].flatMap(adapter.push); + expect(out.map((e) => e.type)).toEqual([ + "step_count", + "model_output_thinking", + "model_output_deep_thinking", + "tool", + "execution_logs", + ]); + expect(out[3].provider_call_id).toHaveLength(309); + expect(out[4].tool_call_id).toBe(out[3].tool_call_id); + expect(out[4].content).toBe("Exact result"); + expect(out[4].is_error).toBe(true); + }); + + it("waits for explicit complete result and emits one original final event", () => { + const adapter = createNexentOutputAdapter(); + adapter.push(body()); + expect(adapter.push({ type: "final_answer", content: "Final" })).toEqual( + [] + ); + expect(adapter.push(terminal(2))).toEqual([]); + const out = adapter.close(); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + type: "final_answer", + content: "Final", + answer_phase: "complete", + reclassify_step: true, + step_id: "final", + }); + expect(adapter.status).toEqual({ type: "complete", reason: "stop" }); + expect(adapter.close()).toEqual([]); + }); + + it.each(["failed", "stopped"])( + "keeps partial without a false final for %s", + (status) => { + const input = [ + body(), + terminal(2, { + status, + final_content: "", + final_block_ids: [], + end_reason: "fault", + }), + ]; + const history = normalizeNexentHistory(saved(input), status); + expect(history.events.some((e) => e.type === "final_answer")).toBe(false); + expect( + history.events.find((e) => e.type === "model_output_thinking")?.content + ).toBe("Final"); + expect(history.status?.type).toBe("incomplete"); + } + ); + + it.each(["missing", "repeat", "order", "after"])( + "fails closed for %s terminal control", + (fault) => { + const adapter = createNexentOutputAdapter(); + adapter.push(body()); + if (fault !== "missing") + adapter.push(terminal(fault === "order" ? 1 : 2)); + if (fault === "repeat") adapter.push(terminal(3)); + if (fault === "after") + adapter.push( + event("model_output", 3, { block_id: "late", content: "Late" }) + ); + expect(adapter.close().every((e) => e.type !== "final_answer")).toBe( + true + ); + expect(adapter.status).toMatchObject({ reason: "error" }); + } + ); + + it("keeps simultaneous child calls with identical local IDs isolated", () => { + const adapter = createNexentOutputAdapter(); + const a = adapter.push( + event("tool", 1, { + turn_id: "a", + invocation_id: "a", + local_call_id: "same", + }) + )[0]; + const b = adapter.push( + event("tool", 1, { + turn_id: "b", + invocation_id: "b", + local_call_id: "same", + }) + )[0]; + const result = adapter.push( + event("tool_result", 2, { + turn_id: "b", + invocation_id: "b", + local_call_id: "same", + result: "B", + }) + )[0]; + expect(a.tool_call_id).not.toBe(b.tool_call_id); + expect(result.tool_call_id).toBe(b.tool_call_id); + expect(adapter.status).toBeUndefined(); + }); + + it("rejects an unpaired result without attaching it to another call", () => { + const adapter = createNexentOutputAdapter(); + expect( + adapter.push( + event("tool_result", 1, { local_call_id: "missing", result: "Wrong" }) + ) + ).toEqual([]); + adapter.push(terminal(2)); + expect(adapter.close()[0].type).toBe("error"); + }); + + it("preserves unknown usage as null and retains explicit zero", () => { + const adapter = createNexentOutputAdapter(); + const e = adapter.push( + event("step_observation", 1, { + usage: { input_tokens: null, output_tokens: 0 }, + step_number: 1, + }) + )[0]; + expect(JSON.parse(e.content)).toMatchObject({ + step_input_tokens: null, + step_output_tokens: 0, + }); + }); + + it("passes all legacy messages through without checking Core names", () => { + const adapter = createNexentOutputAdapter(); + const legacy = { type: "final_answer", content: "Legacy" }; + expect(adapter.push(legacy)).toEqual([legacy]); + expect(adapter.status).toBeUndefined(); + const future = normalizeNexentHistory( + [body(), terminal(2)].map((e) => ({ ...e, agent_core: "future-core" })), + "completed" + ); + expect(future.events.find((e) => e.type === "final_answer")?.content).toBe( + "Final" + ); + expect(future.events.some((e) => e.origin_type === "model_output")).toBe( + false + ); + }); + + it("preserves legacy parent and child final answers in mixed-Core runs", () => { + const adapter = createNexentOutputAdapter(); + expect( + adapter.push({ type: "final_answer", content: "Parent" }) + ).toHaveLength(1); + adapter.push( + event("model_output", 1, { + turn_id: "child", + invocation_id: "child", + block_id: "child", + content: "Child", + }) + ); + expect( + adapter.push({ + type: "final_answer", + invocation_id: "child", + content: "Child", + }) + ).toEqual([]); + adapter.push( + terminal(2, { + turn_id: "child", + invocation_id: "child", + final_content: "Child", + final_block_ids: ["child"], + }) + ); + const out = adapter.push({ + type: "subagent_end", + content: '{"invocation_id":"child"}', + }); + expect(out.map((e) => e.type)).toEqual(["final_answer", "subagent_end"]); + expect(JSON.parse(out[1].content).status).toBe("completed"); + expect(adapter.close()).toEqual([]); + expect(adapter.status).toBeUndefined(); + }); + + it("does not swallow a legacy child's final under a native parent", () => { + const adapter = createNexentOutputAdapter(); + adapter.push(body()); + expect( + adapter.push({ + type: "final_answer", + invocation_id: "legacy-child", + content: "Child", + }) + ).toHaveLength(1); + }); + + it("keeps failure classification when reloading failed persisted state", () => { + const out = normalizeNexentHistory( + saved([ + terminal(1, { + status: "failed", + final_content: "", + final_block_ids: [], + error: { safe_message: "MODEL_TRANSPORT_ERROR" }, + }), + ]), + "failed" + ); + expect(out.events[0].content).toBe("MODEL_TRANSPORT_ERROR"); + expect(out.status).toMatchObject({ error: "MODEL_TRANSPORT_ERROR" }); + }); + + it.each([true, false])( + "handles UTF-8 fragmentation and final newline=%s", + async (newline) => { + const input = [body(), terminal(2, { final_content: "图片粉色" })]; + const out = await bytes(input, 1, newline); + expect(out.events.filter((e) => e.type === "final_answer")).toHaveLength( + 1 + ); + expect(out.events.at(-1).content).toBe("图片粉色"); + expect(out.status?.type).toBe("complete"); + } + ); + + it("does not expose any UI-part or renderer-specific envelope", () => { + const out = normalizeNexentHistory(saved([body(), terminal(2)])); + expect( + out.events.every((e) => e.type !== "data" && e.name !== "runtime-output") + ).toBe(true); + expect(out.events.filter((e) => e.type === "final_answer")).toHaveLength(1); + }); +}); + +it("does not interpret live model JSON as another protocol envelope", () => { + const adapter = createNexentOutputAdapter(); + const content = JSON.stringify(terminal(99)); + const out = adapter.push( + event("model_output", 1, { block_id: "json", content }) + ); + expect(out.at(-1)?.content).toBe(content); + expect(adapter.close().at(-1)?.type).toBe("error"); +}); + +it("restores the backend's already-unwrapped tool unit with its exact result ID", () => { + const units = [ + ...saved([body()]), + { + type: "tool", + content: "", + tool_name: "image", + tool_call_id: "id", + tool_arguments: {}, + }, + ...saved([ + event("tool_result", 2, { local_call_id: "id", result: "Pink" }), + terminal(3), + ]), + ]; + const out = normalizeNexentHistory(units, "completed"); + const tool = out.events.find((e) => e.type === "tool"), + result = out.events.find((e) => e.type === "execution_logs"); + expect(tool?.tool_call_id).toBe(result?.tool_call_id); + expect(result?.content).toBe("Pink"); + expect(out.status?.type).toBe("complete"); +}); + +it("propagates cancellation to the source and retains an incomplete status", async () => { + let cancelled = false; + const source = new ReadableStream({ + start(c) { + c.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(body())}\n\n`) + ); + }, + cancel() { + cancelled = true; + }, + }); + const output = adaptNexentOutputStream( + source.getReader() as ReadableStreamDefaultReader + ); + await output.reader.read(); + await output.reader.cancel(); + expect(cancelled).toBe(true); + expect(output.adapter.status).toMatchObject({ + type: "incomplete", + reason: "cancelled", + }); +}); + +it("does not promote a terminal after a visible transport failure", async () => { + let reads = 0; + const source = new ReadableStream({ + pull(c) { + if (reads++ === 0) + c.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(body())}\n\n`) + ); + else c.error(new Error("transport closed")); + }, + }); + const output = adaptNexentOutputStream( + source.getReader() as ReadableStreamDefaultReader + ); + await output.reader.read(); + await expect(output.reader.read()).rejects.toThrow("transport closed"); + expect(output.adapter.status).toMatchObject({ + type: "incomplete", + reason: "error", + }); +}); diff --git a/frontend/tests/component/subagentRuntimeStatus.test.tsx b/frontend/tests/component/subagentRuntimeStatus.test.tsx new file mode 100644 index 0000000000..f111e3bc2e --- /dev/null +++ b/frontend/tests/component/subagentRuntimeStatus.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SubAgentContainer } from "../../app/[locale]/newchat/ui/subagent"; +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("native child status in legacy invocation cards", () => { + it.each(["failed", "stopped"] as const)( + "does not label a %s native child as completed", + (status) => { + render( + + ); + expect(screen.getByText(`chat.executionStatus.${status}`)).toBeVisible(); + expect(screen.queryByText("Completed")).toBeNull(); + } + ); + it("preserves the legacy completed label without a runtime result", () => { + render( + + ); + expect(screen.getByText("Completed")).toBeVisible(); + }); +}); diff --git a/frontend/tests/workbench/UnifiedOutputStream.test.ts b/frontend/tests/workbench/UnifiedOutputStream.test.ts new file mode 100644 index 0000000000..61d0b30391 --- /dev/null +++ b/frontend/tests/workbench/UnifiedOutputStream.test.ts @@ -0,0 +1,261 @@ +import { expect, it, vi } from "vitest"; +import type { + ChatModelRunOptions, + ChatModelRunResult, +} from "@assistant-ui/react"; +import { ReadableStream } from "node:stream/web"; + +const runAgent = vi.hoisted(() => vi.fn()); +vi.mock("@/services/conversationService", () => ({ + conversationService: { runAgent }, +})); +vi.mock("@/lib/logger", () => ({ + default: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +import { + remoteChatModelAdapter, + attachExecutionLogsToTool, + buildToolCallPart, + parseStepTokenCount, +} from "@/app/newchat/adapter/remote-chat-model-adapter"; + +async function consume(events: Record[]) { + runAgent.mockImplementationOnce(async () => + new ReadableStream({ + start(c) { + for (const e of events) + c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(e)}\n\n`)); + c.close(); + }, + }).getReader() + ); + const options = { + messages: [ + { + id: "q", + role: "user", + content: [{ type: "text", text: "Current task" }], + }, + ], + abortSignal: new AbortController().signal, + context: {}, + runConfig: {}, + unstable_threadId: "local", + } as unknown as ChatModelRunOptions; + const run = remoteChatModelAdapter.run(options); + const results: ChatModelRunResult[] = []; + if (Symbol.asyncIterator in run) + for await (const result of run) results.push(result); + else results.push(await run); + return results; +} +const native = ( + type: string, + sequence: number, + extra: Record = {} +) => ({ + type, + content: "", + schema_version: 1, + runtime_mode: "native_agent", + agent_core: "jiuwen", + turn_id: "root", + sequence, + ...extra, +}); + +it("uses the original reasoning, tool and final parts for both Core event formats", async () => { + const legacy = [ + { type: "step_count", content: "Step 1", step_id: "s1" }, + { type: "model_output_thinking", content: "Before", step_id: "s1" }, + { type: "model_output_deep_thinking", content: "Thought", step_id: "s1" }, + { + type: "tool", + content: "", + tool_name: "mcp", + tool_arguments: { a: 1 }, + tool_call_id: "call", + }, + { type: "execution_logs", content: "Result", tool_call_id: "call" }, + { type: "step_count", content: "Step 2", step_id: "s2" }, + { type: "model_output_thinking", content: "Final", step_id: "s2" }, + { + type: "final_answer", + content: "Final", + step_id: "s2", + answer_phase: "complete", + reclassify_step: true, + }, + ]; + const normalized = [ + native("step_count", 1, { content: "Step 1", step_id: "s1" }), + native("model_output", 2, { + content: "Before", + block_id: "one", + step_id: "s1", + }), + native("model_output_deep_thinking", 3, { + content: "Thought", + block_id: "one", + step_id: "s1", + }), + native("tool", 4, { + local_call_id: "call", + tool_name: "mcp", + tool_arguments: { a: 1 }, + }), + native("tool_result", 5, { local_call_id: "call", result: "Result" }), + native("step_count", 6, { content: "Step 2", step_id: "s2" }), + native("model_output", 7, { + content: "Final", + block_id: "final", + step_id: "s2", + }), + { type: "final_answer", content: "Final" }, + native("run_terminal", 8, { + status: "completed", + final_content: "Final", + final_block_ids: ["final"], + }), + ]; + const summarize = (parts: any[]) => + parts.map((p) => ({ + type: p.type, + text: p.text, + toolName: p.toolName, + argsText: p.argsText, + result: p.result, + })); + const code = (await consume(legacy)).at(-1)!, + jiuwen = (await consume(normalized)).at(-1)!; + expect(summarize(jiuwen.content as any[])).toEqual( + summarize(code.content as any[]) + ); + expect(jiuwen.content?.map((p) => p.type)).toEqual([ + "reasoning", + "tool-call", + "text", + ]); + expect(JSON.stringify(jiuwen)).not.toContain('"runtime-output"'); + expect(jiuwen.status).toMatchObject({ type: "complete" }); +}); + +it("retains a failed tool status in the original ToolFallback input", () => { + const part = buildToolCallPart({ + type: "tool", + content: "", + tool_name: "mcp", + tool_call_id: "a", + }); + expect( + attachExecutionLogsToTool([part], { + type: "execution_logs", + content: "Tool failed", + tool_call_id: "a", + is_error: true, + }) + ).toBe(true); + expect(part.status).toMatchObject({ type: "incomplete", reason: "error" }); + expect(part.result).toBe("Tool failed"); +}); + +it("preserves unknown and explicit zero in the shared usage data", () => { + expect( + parseStepTokenCount('{"step_input_tokens":null,"step_output_tokens":0}') + ).toMatchObject({ + inputKnown: false, + outputKnown: true, + stepOutputTokens: 0, + }); +}); + +it.each(["failed", "stopped"])( + "keeps %s partial in the existing process parts with no final", + async (status) => { + const results = await consume([ + native("model_output", 1, { block_id: "part", content: "Partial" }), + native("run_terminal", 2, { + status, + final_content: "", + final_block_ids: [], + end_reason: "fault", + }), + ]); + const last = results.at(-1)!; + expect(last.status).toMatchObject({ + type: "incomplete", + reason: status === "stopped" ? "cancelled" : "error", + }); + expect( + last.content?.some( + (p) => p.type === "reasoning" && p.text.includes("Partial") + ) + ).toBe(true); + expect(last.content?.some((p) => (p as any).isFinalAnswer)).toBe(false); + } +); + +it("restores an empty usage bucket for a saved simple native answer", async () => { + const { RemoteConversationHistoryAdapter } = + await import("@/app/newchat/adapter/conversation-thread-list-adapter"); + const detail = { + conversation_id: "42", + conversation_title: "Output", + message: [ + { + role: "assistant", + message_id: 10, + status: "completed", + message: [ + { + type: "model_output", + content: JSON.stringify( + native("model_output", 1, { block_id: "final", content: "Final" }) + ), + }, + { + type: "run_terminal", + content: JSON.stringify( + native("run_terminal", 2, { + status: "completed", + final_content: "Final", + final_block_ids: ["final"], + }) + ), + }, + ], + }, + ], + } as unknown as import("@/types/conversation").ApiConversationDetail; + const history = await new RemoteConversationHistoryAdapter( + () => "42", + async () => ({ remoteId: "42", externalId: "42" }), + async () => detail + ).load(); + expect(history.messages[0].message.metadata?.custom?.stepTokenCounts).toEqual( + [] + ); +}); + +it("owns live usage per message and clears it for the next turn", async () => { + const used = ( + await consume([ + { + type: "token_count", + content: JSON.stringify({ + step_input_tokens: 12, + step_output_tokens: 3, + duration: 2, + }), + }, + { type: "final_answer", content: "Done" }, + ]) + ).at(-1)!; + expect(used.metadata?.custom?.stepTokenCounts).toEqual([ + expect.objectContaining({ stepInputTokens: 12, stepOutputTokens: 3 }), + ]); + const fresh = (await consume([{ type: "final_answer", content: "Next" }])).at( + -1 + )!; + expect(fresh.metadata?.custom?.stepTokenCounts).toEqual([]); +}); diff --git a/frontend/types/nexentOutput.ts b/frontend/types/nexentOutput.ts new file mode 100644 index 0000000000..8b21bacbdf --- /dev/null +++ b/frontend/types/nexentOutput.ts @@ -0,0 +1,22 @@ +/** Transport facts accepted at the shared Nexent output boundary. */ +export interface NexentOutputEvent { + type: string; + content: string; + invocation_id?: string; + step_id?: string; + unit_index?: number; + tool_call_id?: string; + tool_name?: string; + tool_arguments?: string | Record; + role?: string; + answer_phase?: "delta" | "complete"; + reclassify_step?: boolean; + [key: string]: unknown; +} + +export type NexentRunStatus = "completed" | "failed" | "stopped"; + +export type NexentOutputStatus = + | { type: "running" } + | { type: "complete"; reason: "stop" } + | { type: "incomplete"; reason: "cancelled" | "error"; error?: string }; diff --git a/sdk/nexent/core/agent_runtime_support/__init__.py b/sdk/nexent/core/agent_runtime_support/__init__.py new file mode 100644 index 0000000000..abcf44ce5a --- /dev/null +++ b/sdk/nexent/core/agent_runtime_support/__init__.py @@ -0,0 +1,5 @@ +"""Shared Agent runtime contracts and scoped support; no engine selection.""" + +from .output import RunResult, StepObservation + +__all__ = ["RunResult", "StepObservation"] diff --git a/sdk/nexent/core/agent_runtime_support/contracts.py b/sdk/nexent/core/agent_runtime_support/contracts.py new file mode 100644 index 0000000000..2f049bf42a --- /dev/null +++ b/sdk/nexent/core/agent_runtime_support/contracts.py @@ -0,0 +1,56 @@ +"""Selected model-visible resources shared by Agent runtime implementations.""" + +from typing import Annotated, Self + +from pydantic import Field, model_validator + +from nexent.core.model_service.contracts import ModelMessage, ToolDefinition +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +class ResourceItem(ProfileContract): + message: ModelMessage + source: NonBlankString + order: Annotated[int, Field(ge=0)] + + +class RuntimeResourceView(ProfileContract): + """Only selected model-visible data; no executable bindings or arbitrary metadata.""" + + instructions: list[ResourceItem] = Field(default_factory=list) + references: list[ResourceItem] = Field(default_factory=list) + history: list[ResourceItem] = Field(default_factory=list) + current_user_input: ResourceItem + tool_observations: list[ResourceItem] = Field(default_factory=list) + tool_definitions: list[ToolDefinition] = Field(default_factory=list) + authorized_metadata: list[ResourceItem] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_resources(self) -> Self: + items = self.items() + if len({item.order for item in items}) != len(items): + raise ValueError("Resource ordering must be unique") + if len({item.message.message_id for item in items}) != len(items): + raise ValueError("Resource message identities must be unique") + if len({tool.name for tool in self.tool_definitions}) != len(self.tool_definitions): + raise ValueError("Authorized tool names must be unique") + for item in items: + message = item.message + if message.tool_calls and message.semantic_role not in {"assistant_history", "assistant"}: + raise ValueError("Tool calls require an assistant history item") + if message.tool_call_id is not None and message.semantic_role not in {"tool_result", "tool"}: + raise ValueError("Tool result association requires an observation") + for group, roles in ( + (self.instructions, {"platform_instruction", "application_instruction"}), + (self.references + self.authorized_metadata, {"reference_data"}), + (self.history, {"user_input", "assistant_history", "tool_result", "user", "assistant", "tool"}), + (self.tool_observations, {"tool_result", "tool"}), + ([self.current_user_input], {"user_input"}), + ): + if any(item.message.semantic_role not in roles for item in group): + raise ValueError("Resource role does not match its authorized group") + return self + + def items(self): + return (self.instructions + self.references + self.history + self.tool_observations + + self.authorized_metadata + [self.current_user_input]) diff --git a/sdk/nexent/core/agent_runtime_support/output.py b/sdk/nexent/core/agent_runtime_support/output.py new file mode 100644 index 0000000000..a9df7c7051 --- /dev/null +++ b/sdk/nexent/core/agent_runtime_support/output.py @@ -0,0 +1,47 @@ +"""Public observations and explicit Agent results, independent of engine memory.""" + +from typing import Literal, Self + +from pydantic import Field, model_validator + +from nexent.core.model_service.contracts import ModelError, TokenUsage +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +class ContextCapacityObservation(ProfileContract): + """Display metadata from the resolved Nexent ContextManager, independent of compaction mode.""" + + context_window_tokens: int | None = Field(default=None, gt=0) + token_threshold: int | None = Field(default=None, gt=0) + effective_input_limit_tokens: int | None = Field(default=None, gt=0) + context_processing_mode: Literal["passthrough", "adaptive_compact"] | None = None + + +class StepObservation(ProfileContract): + turn_id: NonBlankString + block_id: NonBlankString + step_number: int = Field(ge=1) + duration: float = Field(ge=0) + usage: TokenUsage = Field(default_factory=TokenUsage) + finish_reason: str | None = None + step_error: ModelError | None = None + context_capacity: ContextCapacityObservation | None = None + + +class RunResult(ProfileContract): + turn_id: NonBlankString + status: Literal["completed", "failed", "stopped"] + end_reason: NonBlankString + final_content: str = "" + final_block_ids: list[str] = Field(default_factory=list) + error: ModelError | None = None + + @model_validator(mode="after") + def validate_terminal(self) -> Self: + if self.status != "completed" and (self.final_content or self.final_block_ids): + raise ValueError("Unsuccessful runs cannot confirm final content") + if self.status == "completed" and self.error is not None: + raise ValueError("Completed runs cannot carry a run error") + if len(self.final_block_ids) != len(set(self.final_block_ids)): + raise ValueError("Final block identities must be unique") + return self diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index fc2c9cee8a..32cc68e54f 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -50,6 +50,9 @@ def preserve_services(value): class ModelConfig(BaseModel): + resolved_profile: Optional[Any] = Field( + default=None, repr=False, description="Trusted outlet snapshot, validated by the selected runtime adapter", + ) cite_name: str = Field(description="Model alias") api_key: str = Field(description="API key", default="") model_name: str = Field(description="Model call name") @@ -322,6 +325,10 @@ class AgentConfig(BaseModel): ge=1, ) model_name: str = Field(description="Model alias from ModelConfig") + runtime_model_config: Optional[ModelConfig] = Field( + default=None, exclude=True, repr=False, + description="Application-resolved per-agent model binding, isolated from parent overlays", + ) output_protocol: Literal["code_action", "final_envelope"] = Field( description="Closed model-output protocol used by the Agent runtime", default="code_action", @@ -417,6 +424,11 @@ class AgentPlan(BaseModel): class AgentRunInfo(BaseModel): + collaboration_timeout_seconds: float = Field(default=300.0, gt=0, + description="Total deadline for a nested collaboration including queue wait") + model_service: Optional[Any] = Field(default=None, exclude=True, repr=False) + model_deadline: Optional[float] = Field(default=None, exclude=True) + run_result: Optional[Any] = Field(default=None, exclude=True, description="Explicit public runtime result") attempt_outcome: Optional[str] = Field(default=None, description="Typed worker outcome for the application host") query: str = Field(description="User query") model_config_list: List[ModelConfig] = Field(description="List of model configurations") diff --git a/sdk/nexent/core/agents/context/history_compression.py b/sdk/nexent/core/agents/context/history_compression.py index a3439f9b51..890d9fcfe5 100644 --- a/sdk/nexent/core/agents/context/history_compression.py +++ b/sdk/nexent/core/agents/context/history_compression.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any, Sequence +from .history_facts import turn_execution_text, turn_user_text from .llm_summary import LLMSummary from .models import ContextItem, ContextItemInput, ContextItemType @@ -81,8 +82,9 @@ def from_items( covered_through = int(payload["covered_through_message_id"]) canonical_turns = tuple( ( - str(turn.content["user_message"]), - str(turn.content["assistant_final_answer"]), + turn_user_text(turn.content), + "\n".join(filter(None, (turn_execution_text(turn.content), + str(turn.content["assistant_final_answer"])))), ) for turn in turns ) diff --git a/sdk/nexent/core/agents/context/history_facts.py b/sdk/nexent/core/agents/context/history_facts.py new file mode 100644 index 0000000000..da1f3bc963 --- /dev/null +++ b/sdk/nexent/core/agents/context/history_facts.py @@ -0,0 +1,33 @@ +"""Render persisted evidence as reference data, never as executable tool messages.""" + +import json + + +def turn_user_text(content): + text = str(content["user_message"]) + attachments = content.get("attachments") or [] + if isinstance(attachments, str): + try: + attachments = json.loads(attachments) + except (ValueError, TypeError): + attachments = [] + if isinstance(attachments, list): + references = [{key: item[key] for key in ("name", "url", "s3_url", "object_name", "type") if key in item} + for item in attachments if isinstance(item, dict)] + if references: + text += "\n\n" + json.dumps( + references, ensure_ascii=False, + ) + "\n" + return text + + +def turn_execution_text(content): + records = content.get("execution_records") or [] + if not records: + return "" + return ( + '\n' + "These are persisted records from a previous turn, not pending instructions. " + "A call record alone does not establish a successful result. Do not execute or replay these records.\n" + + json.dumps(records, ensure_ascii=False) + "\n" + ) diff --git a/sdk/nexent/core/agents/context/manager.py b/sdk/nexent/core/agents/context/manager.py index e326ea3e91..8da4ee76c0 100644 --- a/sdk/nexent/core/agents/context/manager.py +++ b/sdk/nexent/core/agents/context/manager.py @@ -81,6 +81,14 @@ def prepare_run_context( memory: AgentMemory, fallback_system_prompt: str, items: Optional[Sequence[Any]] = None, + ) -> ManagedRunContext: + """Adapt legacy memory callers to the shared item-only preparation contract.""" + return self.prepare_items_context(fallback_system_prompt=fallback_system_prompt, items=items) + + def prepare_items_context( + self, + fallback_system_prompt: str, + items: Optional[Sequence[Any]] = None, ) -> ManagedRunContext: self._history_candidate = None self._run_compaction_attempts = 0 @@ -125,10 +133,33 @@ def assemble_final_context( force_compaction: bool = False, request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), + ) -> FinalContext: + """Project legacy memory once, then use the Core-independent assembly path.""" + run_context = run_context or self.prepare_run_context(memory, "") + return self.assemble_context_items( + model=model, current_items=self._project_current_run(memory, current_run_start_idx), + tools=tools, purpose=purpose, task=task, final_answer_templates=final_answer_templates, + run_context=run_context, force_compaction=force_compaction, + request_system_messages=request_system_messages, request_tail_messages=request_tail_messages, + ) + + def assemble_context_items( + self, + *, + model: Any, + current_items: Sequence[ContextItem] = (), + tools: Sequence[Any] | None = None, + purpose: str = "step", + task: str | None = None, + final_answer_templates: Optional[Dict[str, Any]] = None, + run_context: ManagedRunContext | None = None, + force_compaction: bool = False, + request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), + request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), ) -> FinalContext: if purpose != "step" and (request_system_messages or request_tail_messages): raise ValueError("Request-only action messages require step purpose") - run_context = run_context or self.prepare_run_context(memory, "") + run_context = run_context or self.prepare_items_context("") policy = resolve_policy(self.config.policy_layers) persisted_items = list(run_context.items) if self._history_candidate is not None: @@ -142,7 +173,6 @@ def assemble_final_context( } ] persisted_items.append(self._history_candidate.as_item()) - current_items = self._project_current_run(memory, current_run_start_idx) items = sorted([*persisted_items, *current_items], key=lambda item: item.layout_key) purpose_stable, purpose_dynamic = self._purpose_messages( purpose=purpose, diff --git a/sdk/nexent/core/agents/context/rendering.py b/sdk/nexent/core/agents/context/rendering.py index 1df89ea44a..d97c5bc6ce 100644 --- a/sdk/nexent/core/agents/context/rendering.py +++ b/sdk/nexent/core/agents/context/rendering.py @@ -10,10 +10,11 @@ from .formatting import ( _format_external_agents_description, - _format_worker_agents_description, _format_skills_inventory, _format_tools_description, + _format_worker_agents_description, ) +from .history_facts import turn_execution_text, turn_user_text from .models import ContextItem, ContextItemType @@ -83,10 +84,12 @@ def _summary_dict_to_markdown(data: dict, labels: dict | None = None) -> str: def _render_turn(item: ContextItem) -> list[dict[str, Any]]: - return [ - _text_message("user", str(item.content["user_message"])), - _text_message("assistant", str(item.content["assistant_final_answer"])), - ] + messages = [_text_message("user", turn_user_text(item.content))] + execution = turn_execution_text(item.content) + if execution: + messages.append(_text_message("assistant", execution)) + messages.append(_text_message("assistant", str(item.content["assistant_final_answer"]))) + return messages def _render_tool_arguments(arguments: Any) -> str: diff --git a/sdk/nexent/core/agents/core_adapters/base.py b/sdk/nexent/core/agents/core_adapters/base.py index 21bb338817..f252261276 100644 --- a/sdk/nexent/core/agents/core_adapters/base.py +++ b/sdk/nexent/core/agents/core_adapters/base.py @@ -29,8 +29,8 @@ def prepare(self) -> None: """Bind resolved resources for this run.""" @abstractmethod - def run(self) -> None: - """Execute and emit the existing Nexent observer protocol.""" + def run(self) -> Any: + """Emit the existing observer protocol and return the terminal value to a delegator.""" def close(self) -> None: """Release only resources owned by this adapter; must be idempotent.""" diff --git a/sdk/nexent/core/agents/core_adapters/children.py b/sdk/nexent/core/agents/core_adapters/children.py new file mode 100644 index 0000000000..231ceb5b48 --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/children.py @@ -0,0 +1,42 @@ +"""Resolve one child invocation without inheriting parent model or conversation state.""" + +from copy import deepcopy + +from ..agent_model import copy_invocation_config +from .factory import AgentCoreFactory + + +def child_run_info(parent, config, scope): + """Keep trusted request resources while selecting the child's frozen model binding.""" + config = copy_invocation_config(config) + models = [model.model_copy(deep=True) for model in parent.model_config_list] + binding = config.runtime_model_config + if binding is not None: + if binding.cite_name != config.model_name: + raise ValueError("Child model binding alias mismatch") + models = [model for model in models if model.cite_name != config.model_name] + models.append(binding.model_copy(deep=True)) + return parent.model_copy(update={ + "agent_config": config, "model_config_list": models, "query": "", + "stop_event": scope.stop_event, "cancellation_scope": scope, + "user_context": deepcopy(parent.user_context), "runtime_metadata": deepcopy(parent.runtime_metadata), + "history": None, "context_input": None, "minio_files": [], "workspace_run_id": None, + "context_budget_snapshot": config.context_budget_snapshot, + "capacity_snapshot": config.capacity_snapshot, + "enable_planning": config.enable_planning, + "thread_execution_id": None, "thread_future": None, "attempt_outcome": None, "run_result": None, + }) + + +def invoke_child(info, task): + """Select the configured Core and close its resources even after partial preparation.""" + if not isinstance(task, str) or not task.strip(): + raise ValueError("Child task must be a non-empty string") + info.query = task + adapter = AgentCoreFactory.create(info) + try: + adapter.validate() + adapter.prepare() + return adapter.run() + finally: + adapter.close() diff --git a/sdk/nexent/core/agents/core_adapters/events.py b/sdk/nexent/core/agents/core_adapters/events.py index fb7d794c7d..d48dd73b7f 100644 --- a/sdk/nexent/core/agents/core_adapters/events.py +++ b/sdk/nexent/core/agents/core_adapters/events.py @@ -1,23 +1,116 @@ -"""Convert native Jiuwen stream output into Nexent observer messages.""" +"""Translate Jiuwen facts to public observations and one explicit run result.""" import json +import threading +import time +import uuid +from ...agent_runtime_support.output import RunResult, StepObservation +from ...model_service.contracts import ModelError from ...utils.observer import ProcessType class JiuwenEventAdapter: - def __init__(self, observer, agent_name): - self.observer = observer - self.agent_name = agent_name + def __init__(self, observer, agent_name, turn_id=None, *, capacity_provider=None): + self.observer, self.agent_name = observer, agent_name + self.turn_id = turn_id or uuid.uuid4().hex self.final_answer = None + self.result = None + self.last_error = None + self.blocks = [] + self.calls = [] + self.sequence = 0 + self.step_number = 0 + self._lock = threading.RLock() + self.capacity_provider = capacity_provider + + def _capacity(self): + return self.capacity_provider() if self.capacity_provider is not None else None + + def output(self, kind, content="", **metadata): + with self._lock: + if self.result is not None: + raise RuntimeError("Runtime output after terminal result") + self.sequence += 1 + self.observer.add_message(self.agent_name, kind, content, runtime_envelope={ + "schema_version": 1, "runtime_mode": "native_agent", "agent_core": "jiuwen", + "turn_id": self.turn_id, "sequence": self.sequence, **metadata, + }) + + def begin_step(self, request): + if self.final_answer is not None: + raise RuntimeError("Model step after Runner terminal") + self.step_number += 1 + self.output(ProcessType.STEP_COUNT, str(self.step_number), step_number=self.step_number) + self.blocks.append({"id": uuid.uuid4().hex, "text": "", "reasoning": "", "started": time.monotonic()}) + + def model_event(self, event): + if event.type == "error": + self.record_error(event.error) + return + block = self.blocks[-1] + if event.type in {"text_delta", "reasoning_delta"}: + reasoning = event.type == "reasoning_delta" + block["reasoning" if reasoning else "text"] += event.delta + self.output(ProcessType.MODEL_OUTPUT_DEEP_THINKING if reasoning else ProcessType.MODEL_OUTPUT, + event.delta, block_id=block["id"], phase="partial") + elif event.type == "message_completed": + message = event.message + for key, parts, kind in (("reasoning", message.reasoning_parts, ProcessType.MODEL_OUTPUT_DEEP_THINKING), + ("text", message.content_parts, ProcessType.MODEL_OUTPUT)): + text = "".join(part.get("text", "") for part in parts if part.get("type") == "text") + if not block[key] and text: + block[key] = text + self.output(kind, text, block_id=block["id"], phase="partial") + block["observed"] = True + self.calls.extend(message.tool_calls) + observation = StepObservation(turn_id=self.turn_id, block_id=block["id"], + step_number=self.step_number, duration=max(0, time.monotonic() - block["started"]), + usage=message.usage, finish_reason=message.finish_reason, context_capacity=self._capacity()) + self.output(ProcessType.STEP_OBSERVATION, observation.model_dump_json(), + block_id=block["id"], phase="intermediate", + **observation.model_dump(exclude={"turn_id", "block_id"})) + + def record_error(self, error): + self.last_error = error + if self.blocks and not self.blocks[-1].get("observed"): + block = self.blocks[-1] + block["observed"] = True + observation = StepObservation(turn_id=self.turn_id, block_id=block["id"], + step_number=self.step_number, duration=max(0, time.monotonic() - block["started"]), step_error=error, + context_capacity=self._capacity()) + self.output(ProcessType.STEP_OBSERVATION, observation.model_dump_json(), block_id=block["id"], + phase="partial", **observation.model_dump(exclude={"turn_id", "block_id"})) + + def recovery(self, details): + """Keep the rejected observation, but do not poison a later successful result.""" + self.output(ProcessType.OTHER, json.dumps({"type": "logical_recovery", **details}), + phase="intermediate") + self.last_error = None + + def tool_call(self, name, arguments, call_id): + provider_id = None + with self._lock: + for index, call in enumerate(self.calls): + if call.name == name and call.parsed_arguments == arguments: + provider_id = self.calls.pop(index).provider_call_id + break + self.output(ProcessType.TOOL, "", local_call_id=call_id, provider_call_id=provider_id, + tool_call_id=call_id, tool_name=name, tool_arguments=arguments, + raw_arguments=json.dumps(arguments, ensure_ascii=False)) + return provider_id + + def tool_result(self, call_id, provider_id, result, is_error=False): + self.output(ProcessType.TOOL_RESULT, str(result), local_call_id=call_id, + tool_call_id=call_id, provider_call_id=provider_id, result=str(result), is_error=is_error) def emit(self, chunk): - kind = getattr(chunk, "type", None) - payload = getattr(chunk, "payload", None) + kind, payload = getattr(chunk, "type", None), getattr(chunk, "payload", None) if isinstance(chunk, dict): kind, payload = chunk.get("type", kind), chunk.get("payload", payload) if kind == "answer": if self.final_answer is not None: + self.last_error = self.error("JIUWEN_DUPLICATE_TERMINAL") raise RuntimeError("Jiuwen emitted multiple terminal answers") if not isinstance(payload, dict) or payload.get("result_type") == "error": raise RuntimeError("Jiuwen agent execution failed") @@ -25,17 +118,39 @@ def emit(self, chunk): if not isinstance(output, str) or not output.strip(): raise RuntimeError("Jiuwen returned no final answer") self.final_answer = output - elif kind == "llm_output" and isinstance(payload, dict): - content = payload.get("content") - if isinstance(content, str) and content: - self.observer.add_message(self.agent_name, ProcessType.MODEL_OUTPUT_THINKING, content) - reasoning = payload.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - self.observer.add_message(self.agent_name, ProcessType.MODEL_OUTPUT_DEEP_THINKING, reasoning) elif kind == "context.usage" and isinstance(payload, dict): self.observer.add_message(self.agent_name, ProcessType.TOKEN_COUNT, json.dumps(payload)) + # ModelService owns ordered content/reasoning; native llm_output would duplicate it. + + def error(self, category): + return ModelError(category=category, scope="protocol", retryable=False, safe_message=category, + request_id=self.turn_id) - def finish(self): - if self.final_answer is None: - raise RuntimeError("Jiuwen stream ended without a terminal answer") - self.observer.add_message(self.agent_name, ProcessType.FINAL_ANSWER, self.final_answer) + def finish(self, status="completed", error=None): + if self.result is not None: + return self.result + error = error or self.last_error + if status == "completed" and self.final_answer is None: + error = self.error("JIUWEN_TERMINAL_MISSING") + if error is not None and status == "completed": + status = "failed" + if status == "failed" and error is None: + error = self.error("JIUWEN_EXECUTION_FAILED") + final_ids = [] + if status == "completed": + matching = next((b for b in reversed(self.blocks) if b["text"] == self.final_answer), None) + if matching is None: + matching = {"id": uuid.uuid4().hex, "text": self.final_answer} + self.blocks.append(matching) + self.output(ProcessType.MODEL_OUTPUT, self.final_answer, block_id=matching["id"], phase="partial") + final_ids = [matching["id"]] + result = RunResult(turn_id=self.turn_id, status=status, + end_reason="runner_answer" if status == "completed" else "cancelled" if status == "stopped" + else (error.category if error else "JIUWEN_EXECUTION_FAILED"), + final_content=self.final_answer if status == "completed" else "", final_block_ids=final_ids, + error=error if status != "completed" else None) + if status == "completed": + self.observer.add_message(self.agent_name, ProcessType.FINAL_ANSWER, self.final_answer) + self.output(ProcessType.RUN_TERMINAL, result.model_dump_json(), **result.model_dump(exclude={"turn_id"})) + self.result = result + return result diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/core_adapters/jiuwen.py index c025409fc9..a43cdfce18 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen.py +++ b/sdk/nexent/core/agents/core_adapters/jiuwen.py @@ -4,8 +4,10 @@ import inspect import json import logging +import time import uuid -from contextlib import aclosing +from contextlib import aclosing, contextmanager +from contextvars import ContextVar from types import SimpleNamespace from ...concurrency import ManagedTaskSpec, RunCancellationScope @@ -70,26 +72,30 @@ def __init__(self, run_info): self.native_agent = None self.resources = None self.mcp = None + self.context_bridge = None self.owner_id = f"nexent_{uuid.uuid4().hex}" self.session_id = f"{self.owner_id}_session" + self.events = JiuwenEventAdapter( + getattr(run_info, "observer", None), + getattr(getattr(run_info, "agent_config", None), "name", "jiuwen"), self.owner_id, + capacity_provider=lambda: self.context_bridge.capacity_metadata() if self.context_bridge else None) + self._active_resource = ContextVar("jiuwen_resource", default=None) + self._run_completed = False self.tool_configs = [] self.visible_tool_names = set() self.business_tools = {} self.prefix_messages = [] self._closed = False self._tool_executions = [] + self.text_protocol = False @classmethod def capabilities(cls): - return frozenset({"tools", "mcp", "skills", "streaming", "history", "cancellation"}) + return frozenset({"tools", "mcp", "skills", "streaming", "history", "cancellation", "workers", "a2a"}) def validate(self): config = self.run_info.agent_config unsupported = [] - if config.worker_agents: - unsupported.append("worker_agents") - if config.external_a2a_agents: - unsupported.append("external_a2a_agents") if config.enable_planning: unsupported.append("enable_planning") if config.verification_config.enabled or ( @@ -100,12 +106,6 @@ def validate(self): unsupported.append("smolagents output protocol") if config.provide_run_summary: unsupported.append("provide_run_summary") - if config.context_manager_config is not None: - from ..context.policy import resolve_policy - - policy = resolve_policy(config.context_manager_config.policy_layers) - if policy.processing_mode.value != "passthrough": - unsupported.append("adaptive context compaction (disable enable_context_manager for Jiuwen)") if config.model_name and any(model.cite_name == config.model_name and model.prompt_cache for model in self.run_info.model_config_list): unsupported.append("provider prompt_cache policy") @@ -116,10 +116,27 @@ def validate(self): ) if self.model_config is None: raise ValueError(f"Model alias not found: {config.model_name}") + profile = self.model_config.resolved_profile + from ...model_service.profiles.resolved_profile import ResolvedModelProfile + + if not isinstance(profile, ResolvedModelProfile): + raise UnsupportedAgentCoreConfig("Jiuwen requires a trusted resolved model outlet snapshot") + if profile.outlet_id is None: + raise UnsupportedAgentCoreConfig("Jiuwen requires a saved model outlet identity") + if str(self.run_info.tenant_id or "") != profile.identity.tenant_id: + raise UnsupportedAgentCoreConfig("Jiuwen model outlet tenant mismatch") + if (self.model_config.model_name != profile.identity.request_model_id + or self.model_config.url.rstrip("/") != profile.connection.base_url.rstrip("/")): + raise UnsupportedAgentCoreConfig("Jiuwen model outlet identity mismatch") + self.text_protocol = profile.capabilities.tools.status != "supported" self.tool_configs = list(config.tools) registry = {tool.name or tool.class_name: tool for tool in self.tool_configs} if len(registry) != len(self.tool_configs): raise ValueError("Duplicate tool names in agent configuration") + from .jiuwen_protocol import EXECUTION_TOOL + + if EXECUTION_TOOL in registry: + raise UnsupportedAgentCoreConfig("Reserved Jiuwen execution tool name") self.visible_tool_names = set(registry) if config.prompt_tool_policy_snapshot is not None: policy = PromptToolPolicySnapshot.from_mapping(config.prompt_tool_policy_snapshot) @@ -130,22 +147,50 @@ def validate(self): ) self.tool_configs = list(effective.execution_tools.values()) self.visible_tool_names = set(effective.tools) + collaborator_names = [child.invocation_name or child.name for child in config.worker_agents] + collaborator_names.extend(child.name for child in config.external_a2a_agents) + all_names = [*registry, *collaborator_names] + if len(all_names) != len(set(all_names)) or EXECUTION_TOOL in collaborator_names: + raise UnsupportedAgentCoreConfig("Duplicate or reserved collaboration tool name") + self.visible_tool_names.update(collaborator_names) if any(tool.source == "mcp" for tool in self.tool_configs) and not self.run_info.mcp_host: raise ValueError("Authorized MCP tools require resolved MCP hosts") + @contextmanager def _observe_tool(self, name, arguments): call_id = uuid.uuid4().hex - self.run_info.observer.add_message( - self.run_info.agent_config.name, ProcessType.TOOL, "", - tool_name=name, tool_arguments=arguments, tool_call_id=call_id, - ) - return self.run_info.observer.tool_call_context(call_id) + provider_id = self.events.tool_call(name, arguments, call_id) + token = self._active_resource.set((call_id, provider_id)) + try: + with self.run_info.observer.tool_call_context(call_id): + try: + yield + except BaseException as error: + self.events.tool_result(call_id, provider_id, type(error).__name__, is_error=True) + raise + finally: + self._active_resource.reset(token) + + def _resource_result(self, result): + active = self._active_resource.get() + if active is not None: + value = result.model_dump() if hasattr(result, "model_dump") else result + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + pass + is_error = isinstance(value, dict) and (value.get("success") is False or value.get("isError") is True + or value.get("status") in ("error", "failed")) + rendered = result if isinstance(result, str) else json.dumps(value, ensure_ascii=False, default=str) + self.events.tool_result(*active, rendered, is_error) + # Keep legacy side channels for artifacts and existing consumers. + self.run_info.observer.add_message(self.run_info.agent_config.name, ProcessType.EXECUTION_LOGS, str(result)) def prepare(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard from openjiuwen.core.single_agent import AgentCard from openjiuwen.harness.factory import create_deep_agent - from openjiuwen.harness.rails.model_anomaly_detection_rail import ModelAnomalyDetectionRail from ..run_agent import _get_default_agent_thread_manager @@ -161,6 +206,7 @@ def prepare(self): workspace_path=info.workspace_path, workspace_run_id=info.workspace_run_id, minio_files=info.minio_files, cancellation_scope=self.scope, user_context=info.user_context, ) + self.resources.core_run_info = info native_tools = [] for config in self.tool_configs: if config.source == "mcp" or config.class_name == "ParallelExecutorTool": @@ -173,18 +219,49 @@ def prepare(self): ToolCard(name=name, description=config.description or tool.description, input_params=_tool_schema(tool.inputs)), self._business_handler(name, tool.forward), )) + for child in info.agent_config.worker_agents: + tool = self.resources._create_subagent_definition(child, {}) + self.business_tools[tool.name] = tool + native_tools.append(LocalFunction( + ToolCard(name=tool.name, description=tool.description, input_params=_tool_schema(tool.inputs)), + self._business_handler(tool.name, tool, lane="control-io"), + )) + for external in info.agent_config.external_a2a_agents: + from ..a2a_agent_proxy import ExternalA2AAgentWrapper + from ..subagent_wrapper import SubAgentDefinition + + definition = SubAgentDefinition(external.name, external.description or external.name) + + def invoke_remote(task, _config=external): + # A proxy owns a single call; parallel siblings never share client/task state. + if self.scope.cancelled: + raise RunTerminated() + remote = ExternalA2AAgentWrapper( + _config.to_a2a_agent_info(), stop_event=self.scope.stop_event, + observer=info.observer, cancellation_scope=self.scope, user_context=info.user_context, + ) + remote.set_runtime_metadata(info.runtime_metadata) + return remote(task=task) + + definition.forward = invoke_remote + tool = self.resources._wrap_subagent(definition, external, agent_id=str(external.agent_id)) + self.business_tools[tool.name] = tool + native_tools.append(LocalFunction( + ToolCard(name=tool.name, description=tool.description, input_params=_tool_schema(tool.inputs)), + self._business_handler(tool.name, tool.forward, lane="control-io"), + )) # Reuse file/sandbox infrastructure, never construct a smolagents Agent. self.resources.agent = SimpleNamespace(tools=self.business_tools, python_executor=None) self._bind_skill_sandbox() self.query = self.resources._prepare_file_workspace(info.query) prompt = self._compile_context() rails = self._skill_rails() - # Retry only the native model call, never a completed business tool or run. - rails.append(ModelAnomalyDetectionRail( - max_retries=0, transient_max_retries=1, transient_base_delay_seconds=0.5, - tool_loop_compact={"enabled": False}, - )) model = self._create_model() + from .jiuwen_context import JiuwenContextBridge + + self.context_bridge = JiuwenContextBridge(info, self.scope, self.manager, model, self.context_items) + model.prepare_messages = self.context_bridge.prepare_messages + model.prefix = [] self.native_agent = create_deep_agent( model, card=AgentCard(id=self.owner_id, name=info.agent_config.name, description=info.agent_config.description), @@ -192,13 +269,16 @@ def prepare(self): max_iterations=info.agent_config.max_steps, workspace=info.workspace_path, enable_task_loop=False, enable_sys_operation=False, enable_read_image_multimodal=False, enable_model_anomaly_detection_rail=False, language="cn" if info.observer.lang == "zh" else "en", - parallel_tool_calls=True, + enable_tool_resilience_rail=False, enable_skill_discovery=False, + parallel_tool_calls=False, ) if any(config.class_name == "ParallelExecutorTool" and (config.name or "parallel_executor") in self.visible_tool_names for config in self.tool_configs): self._bind_parallel_tool() + if self.text_protocol: + self._bind_text_executor() - def _business_handler(self, name, forward): + def _business_handler(self, name, forward, *, lane="model-tool-io"): async def execute(**arguments): if self.scope.cancelled: raise asyncio.CancelledError() @@ -207,18 +287,37 @@ async def execute(**arguments): result = await forward(**arguments) else: execution = self.manager.submit( - "model-tool-io", ManagedTaskSpec(task_name=f"jiuwen-tool-{name}", owner=__name__), + lane, ManagedTaskSpec(task_name=f"jiuwen-tool-{name}", owner=__name__), forward, **arguments, ) self._tool_executions.append(execution) try: - result = await asyncio.wrap_future(execution.future) + if lane == "control-io": + timeout = self.run_info.collaboration_timeout_seconds + if self.run_info.model_deadline is not None: + timeout = min(timeout, max(0.0, self.run_info.model_deadline - time.monotonic())) + result = await asyncio.wait_for(asyncio.wrap_future(execution.future), timeout) + else: + result = await asyncio.wrap_future(execution.future) + except TimeoutError: + if lane != "control-io": + raise + logger.warning( + "event=agent_collaboration_timeout agent_name=%s timeout_seconds=%.3f " + "phase=queue_or_execution error_type=TimeoutError", + name, timeout, + ) + self.scope.cancel() + self.manager.cancel(execution.execution_id, reason="Collaboration deadline exceeded", + wait_timeout=0, mark_stuck_on_timeout=False) + raise except asyncio.CancelledError: self.manager.cancel(execution.execution_id, reason="Jiuwen run cancelled", wait_timeout=0, mark_stuck_on_timeout=False) raise - self.run_info.observer.add_message(self.run_info.agent_config.name, ProcessType.EXECUTION_LOGS, - str(result)) + if inspect.isawaitable(result): + result = await result + self._resource_result(result) return result return execute @@ -242,6 +341,7 @@ def _compile_context(self): ContextItemType.WORKER_AGENT, ContextItemType.EXTERNAL_AGENT, ContextItemType.CURRENT_TASK}: context.append(ContextItem.from_input(item)) + self.context_items = context self.prefix_messages = ContextItemRenderer().render(context) self.prefix_messages.extend( {"role": history.role, "content": history.content} @@ -257,81 +357,90 @@ def _compile_context(self): return prompt + "\n\n" + current_task_guidance def _create_model(self): - from openjiuwen.core.foundation.llm import Model - from openjiuwen.core.foundation.llm.schema.config import ModelClientConfig, ModelRequestConfig - - from ...models.model_concurrency import model_concurrency_limiter + from ...model_service.registry import AuthenticationRegistry + from ...model_service.service import ModelService from ...utils.token_estimation import estimate_tokens_text + from .jiuwen_model import JiuwenServiceModel + + info, config = self.run_info, self.model_config + profile = config.resolved_profile + output_cap = profile.parameter_values.get("max_output_tokens", profile.parameter_values.get("max_tokens")) + budget = info.context_budget_snapshot or info.agent_config.context_budget_snapshot + if (budget is None and info.agent_config.requested_output_tokens is not None + and info.agent_config.requested_output_tokens != output_cap): + raise UnsupportedAgentCoreConfig( + "Resolve the Agent output override into the outlet snapshot before running") + if budget is not None and budget.requested_output_tokens != output_cap: + raise UnsupportedAgentCoreConfig("Resolve the output budget into the outlet snapshot before running") + service = info.model_service + if service is None: + # Capture credentials before any later configuration mutation. + credential = config.api_key + tenant = profile.identity.tenant_id + reference = profile.connection.credential_ref + + def lookup(selected_tenant, selected_reference): + if selected_tenant != tenant or selected_reference != reference: + raise PermissionError("Model credential scope mismatch") + return credential + + auth = AuthenticationRegistry() + service = ModelService(lambda snapshot: auth.sender_for(snapshot, lookup)) + step = 0 + + def on_step(_request): + nonlocal step + step += 1 + self.events.begin_step(_request) + + def on_event(event): + self.events.model_event(event) + if event.type == "message_completed": + info.observer.add_message(info.agent_config.name, ProcessType.TOKEN_COUNT, + event.message.usage.model_dump_json()) + + def validate_input(view): + budget = getattr(info.agent_config.context_manager_config, "effective_input_limit_tokens", 0) + if budget and estimate_tokens_text(view.model_dump_json()) > budget: + raise UnsupportedAgentCoreConfig("Jiuwen input exceeds the resolved Nexent context budget") + + return JiuwenServiceModel(service, profile, self.scope, prefix=self.prefix_messages, + run_id=self.owner_id, deadline=info.model_deadline, + on_step=on_step, on_event=on_event, validate_input=validate_input, + on_error=self.events.record_error, on_recovery=self.events.recovery) + + def _bind_text_executor(self): + from openjiuwen.core.foundation.tool import LocalFunction, ToolCard - config = self.model_config - prefix = self.prefix_messages - info = self.run_info - budget = getattr(info.agent_config.context_manager_config, "effective_input_limit_tokens", 0) - model_step = 0 - - def start_model_call(): - nonlocal model_step - model_step += 1 - info.observer.add_message(info.agent_config.name, ProcessType.STEP_COUNT, str(model_step)) - - def acquire(messages, tools): - if budget: - rendered = json.dumps([message.model_dump() if hasattr(message, "model_dump") else message - for message in messages], ensure_ascii=False, default=str) - schemas = json.dumps(tools or [], ensure_ascii=False, default=str) - if estimate_tokens_text(rendered + schemas) > budget: - raise UnsupportedAgentCoreConfig("Jiuwen input exceeds the resolved Nexent context budget") - if config.concurrency_limit is not None: - return model_concurrency_limiter.acquire( - (str(info.tenant_id or "default"), str(config.model_factory or "unknown"), config.model_name), - config.concurrency_limit, config.timeout_seconds or 300, info.stop_event, - ) - return None - - class RunModel(Model): - async def invoke(model_self, messages, **kwargs): - start_model_call() - messages = _prepend_context_messages(messages, prefix) - permit = acquire(messages, kwargs.get("tools")) - try: - return await super().invoke(messages, **kwargs) - finally: - if permit: - permit.release() - - async def stream(model_self, messages, **kwargs): - start_model_call() - messages = _prepend_context_messages(messages, prefix) - permit = acquire(messages, kwargs.get("tools")) - try: - async for chunk in super().stream(messages, **kwargs): - yield chunk - finally: - if permit: - permit.release() - - timeout = config.timeout_seconds or 300.0 - return RunModel( - ModelClientConfig( - client_provider="OpenAI", api_key=config.api_key, api_base=config.url, - auth_mode="api_key" if config.api_key else "none", - timeout=timeout, stream_first_chunk_timeout=timeout, stream_idle_timeout=min(timeout, 60), - max_retries=0, use_shared_llm_http_client=False, verify_ssl=config.ssl_verify is not False, - extensions={"request_extra_body": config.extra_body or {}}, - ), - ModelRequestConfig( - model=config.model_name, temperature=config.temperature, top_p=config.top_p, - max_tokens=self.run_info.agent_config.requested_output_tokens or config.max_output_tokens, - context_window=config.context_window_tokens, - reasoning={"mode": "enabled" if config.enable_thinking else "disabled", - "effort": config.reasoning_effort, "budget_tokens": config.reasoning_budget_tokens}, - ), + from ..sandbox import SandboxConfig, _execute_with_tool_context, build_python_executor + from .jiuwen_protocol import EXECUTION_TOOL + + executor = self.resources.agent.python_executor + if executor is None: + executor = build_python_executor(self.run_info.sandbox_config or SandboxConfig(), logger, + host_tools_exist=True, cancellation_scope=self.scope) + self.resources._sandbox_executors.append(executor) + self.resources.agent.python_executor = executor + + def execute(code): + # The sandbox lane waits on the run loop, never on another sandbox execution. + # Native and text calls share the exact ability, including MCP deadlines/identity. + executor.send_tools(self.resource_bridge.bindings()) + output = _execute_with_tool_context(executor, code) + return json.dumps({"output": str(output.output), "logs": output.logs, + "is_final_answer": output.is_final_answer}, ensure_ascii=False) + + card = ToolCard(name=EXECUTION_TOOL, description="Execute a validated controlled text block", + input_params={"type": "object", "properties": {"code": {"type": "string"}}, + "required": ["code"], "additionalProperties": False}) + self.native_agent.ability_manager.add_ability( + card, LocalFunction(card, self._business_handler(EXECUTION_TOOL, execute, lane="sandbox")), ) def _skill_rails(self): from .skills import NexentSkillRail - directories, names = set(), set() + directories, names, authorized_by_root = set(), set(), {} for config in self.tool_configs: if config.class_name not in {"ReadSkillMdTool", "RunSkillScriptTool", "ReadSkillConfigTool"}: continue @@ -342,7 +451,9 @@ def _skill_rails(self): if parameters.get("local_skills_dir"): directories.add(parameters["local_skills_dir"]) names.update(authorized) - return [NexentSkillRail(sorted(directories), sorted(names))] if directories and names else [] + authorized_by_root.setdefault(parameters["local_skills_dir"], set()).update(authorized) + return [NexentSkillRail(sorted(directories), sorted(names), authorized_by_root=authorized_by_root)] \ + if directories and names else [] def _bind_skill_sandbox(self): from ..sandbox import SandboxLevel, SandboxSkillScriptRunner, build_python_executor @@ -360,35 +471,52 @@ def _bind_skill_sandbox(self): self.resources._sandbox_skill_runners.append(runner) for tool in self.business_tools.values(): if getattr(tool, "name", None) == "run_skill_script": - tool.bind_execution_backend(runner, on_complete=lambda _result: self.resources._pull_file_workspace_from_sandbox()) + tool.bind_execution_backend( + runner, on_complete=lambda _result: self.resources._pull_file_workspace_from_sandbox()) def _bind_parallel_tool(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard - from openjiuwen.core.runner import Runner - async def parallel(tasks): - async def invoke(task): + from ...tools.parallel_executor import ParallelExecutorTool + + # Reuse the existing scheduler/results/timeout contract. Bridge waiters + # use control-io; actual resource effects still use model-tool-io. + scheduler = ParallelExecutorTool(execution_lane="control-io") + + def parallel(tasks, timeout=None, max_workers=4): + bindings = self.resource_bridge.bindings() + prepared = [] + for task in tasks: name = task["tool_name"] - if name == "parallel_executor": - raise ValueError("Recursive parallel executor is not allowed") - card = self.native_agent.ability_manager.get(name) - if card is None: - raise PermissionError("Parallel task references an unavailable tool") - tool = Runner.resource_mgr.get_tool(card.id) - return await tool.invoke(task.get("arguments", {})) - - with self._observe_tool("parallel_executor", {"tasks": tasks}): - return await asyncio.gather(*(invoke(task) for task in tasks)) - - card = ToolCard(name="parallel_executor", description="Execute independent authorized native tools concurrently.", + if name == "parallel_executor" or name not in bindings: + raise PermissionError("Parallel task references an unavailable resource") + item = (bindings[name], task.get("arguments") or {}) + if task.get("result_key") is not None: + item += (task["result_key"],) + prepared.append(item) + return scheduler.forward(prepared, timeout=timeout, max_workers=max_workers) + + card = ToolCard(name="parallel_executor", description="Execute independent authorized resources concurrently.", input_params={"type": "object", "properties": {"tasks": {"type": "array", "maxItems": 16, "items": {"type": "object", "properties": {"tool_name": {"type": "string"}, - "arguments": {"type": "object"}}, "required": ["tool_name"]}}}, "required": ["tasks"]}) - self.native_agent.ability_manager.add_ability(card, LocalFunction(card, parallel)) + "arguments": {"type": "object"}, "result_key": {"type": "string"}}, + "required": ["tool_name"], "additionalProperties": False}}, + "timeout": {"type": "integer", "minimum": 1, "default": 120}, + "max_workers": {"type": "integer", "minimum": 1, "maximum": 16, "default": 4}}, + "required": ["tasks"], "additionalProperties": False}) + self.native_agent.ability_manager.add_ability( + card, LocalFunction( + card, self._business_handler("parallel_executor", parallel, lane="background-service")), + ) def run(self): try: - asyncio.run(self._run_async()) + answer = asyncio.run(self._run_async()) + if self.events.final_answer is None: + self.events.last_error = self.events.error("JIUWEN_TERMINAL_MISSING") + raise RuntimeError("Jiuwen stream ended without a terminal answer") + self._run_completed = True + return answer except asyncio.CancelledError: raise RunTerminated() from None @@ -400,6 +528,9 @@ async def _run_async(self): info = self.run_info current = asyncio.current_task() loop = asyncio.get_running_loop() + from .jiuwen_resources import JiuwenResourceBridge + + self.resource_bridge = JiuwenResourceBridge(self.native_agent, loop, self.scope, self.visible_tool_names) token = self.scope.register_closer(lambda: loop.call_soon_threadsafe(current.cancel)) async def watch_stop(): @@ -408,18 +539,18 @@ async def watch_stop(): current.cancel() watcher = asyncio.create_task(watch_stop()) - events = JiuwenEventAdapter(info.observer, info.agent_config.name) + events = self.events try: mcp_tools = [tool for tool in self.tool_configs if tool.source == "mcp" and (tool.name or tool.class_name) in self.visible_tool_names] if mcp_tools: self.mcp = JiuwenMCPCollection( authorized_tools=mcp_tools, observe=self._observe_tool, manager=self.manager, - emit_result=lambda result: info.observer.add_message( - info.agent_config.name, ProcessType.EXECUTION_LOGS, str(result)), + emit_result=self._resource_result, user_context=info.user_context, server_parameters=info.mcp_host, cancellation_scope=self.scope, - tool_timeout_seconds=info.mcp_tool_timeout_seconds, close_timeout_seconds=info.mcp_close_timeout_seconds, + tool_timeout_seconds=info.mcp_tool_timeout_seconds, + close_timeout_seconds=info.mcp_close_timeout_seconds, connect_timeout_seconds=info.mcp_request_timeout_seconds or 10.0, ) collection = self.mcp.__enter__() @@ -427,7 +558,8 @@ async def watch_stop(): self.native_agent.ability_manager.add_ability(tool.card, tool) info.observer.add_message(info.agent_config.name, ProcessType.AGENT_NEW_RUN, self.query) for event in info.agent_config.pre_run_tool_events: - info.observer.add_message(info.agent_config.name, ProcessType.OTHER, json.dumps(event, ensure_ascii=False)) + info.observer.add_message( + info.agent_config.name, ProcessType.OTHER, json.dumps(event, ensure_ascii=False)) async with aclosing(Runner.run_agent_streaming( self.native_agent, {"query": self.query, "conversation_id": self.session_id}, session=self.session_id, )) as stream: @@ -439,7 +571,7 @@ async def watch_stop(): events.emit(chunk) if self.scope.cancelled: raise asyncio.CancelledError() - events.finish() + return events.final_answer finally: watcher.cancel() await asyncio.gather(watcher, return_exceptions=True) @@ -452,9 +584,24 @@ async def watch_stop(): Runner.resource_mgr.remove_tool(card.id) def close(self): + if self._closed: + return + try: + self._close_resources() + except BaseException: + self._run_completed = False + raise + finally: + status = "stopped" if self.run_info.stop_event.is_set() else ( + "completed" if self._run_completed else "failed") + self.run_info.run_result = self.events.finish(status) + + def _close_resources(self): if self._closed: return self._closed = True + if self.context_bridge is not None: + self.context_bridge.close() if self.native_agent is not None: from openjiuwen.core.runner import Runner @@ -470,6 +617,7 @@ def close(self): self.mcp.close() finally: if self.resources: + self.resources._close_subagent_invocations() try: self.resources._finalize_file_workspace() finally: diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_context.py b/sdk/nexent/core/agents/core_adapters/jiuwen_context.py new file mode 100644 index 0000000000..c10eac698c --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/jiuwen_context.py @@ -0,0 +1,192 @@ +"""Adapt native messages to the existing ContextManager without synthetic Agent memory.""" + +import asyncio +import json +import logging +import time +from copy import copy +from types import SimpleNamespace + +from ...agent_runtime_support.output import ContextCapacityObservation +from ...concurrency import ManagedTaskSpec +from ...concurrency.cancellation import RunTerminated +from ...model_service.compatibility.invocation_options import LegacyInvocationOptions +from ...utils.observer import ProcessType +from ..context import ContextItem, ContextItemInput, ContextManager, ContextManagerConfig +from ..context.models import CurrentActionContextItem +from .jiuwen_messages import _mapping, resource_view + + +logger = logging.getLogger(__name__) + + +class NativeActionItem(CurrentActionContextItem): + """Keep live native call/result pairs intact while completed DB turns compact.""" + + SUPPORTS_COMPACT = False + + +class SummaryModel: + """Synchronous CM model seam using the same saved outlet and ModelService.""" + + def __init__(self, model): + self.model = model + self.model_id = model.profile.identity.request_model_id + + def __call__(self, messages, *, stop_sequences=None, max_tokens=None): + if self.model.scope.cancelled: + raise RunTerminated() + if stop_sequences: + raise ValueError("Summary stop overrides are not supported") + parameters = {"max_tokens": max_tokens} if max_tokens is not None else {} + if any(rule["key"] == "enable_thinking" for rule in self.model.profile.parameter_rules): + parameters["enable_thinking"] = False + profile = LegacyInvocationOptions(parameters=parameters).resolve(self.model.profile) + normalized = [message if isinstance(message, dict) else { + "role": getattr(message.role, "value", message.role), "content": message.content, + } for message in messages] + view = resource_view(normalized, []) + request = self.model.converter.convert(view, profile, stream=False, run_id=self.model.run_id).request + message = self.model.service.invoke(request, profile, scope=self.model.scope, deadline=self.model.deadline) + if self.model.scope.cancelled: + raise RunTerminated() + if message.finish_reason != "stop" or message.tool_calls or message.refusal is not None: + raise ValueError("Invalid context summary completion") + if any(part.get("type") != "text" for part in message.content_parts): + raise ValueError("Non-text context summary") + return SimpleNamespace( + content="".join(part["text"] for part in message.content_parts), + token_usage=message.usage, + ) + + +class JiuwenContextBridge: + """Own one run-local CM; persisted facts and checkpoints remain application-owned.""" + + def __init__(self, info, scope, manager, model, items): + self.info, self.scope, self.manager = info, scope, manager + config = copy(info.agent_config.context_manager_config or ContextManagerConfig()) + sink = config.history_summary_sink + if sink is not None: + def guarded_sink(candidate): + if scope.cancelled: + raise RunTerminated() + return sink(candidate) + config.history_summary_sink = guarded_sink + status_sink = config.history_summary_status_sink + + def emit_status(payload): + if status_sink is not None: + status_sink(payload) + info.observer.add_message(info.agent_config.name, ProcessType.HISTORY_SUMMARY, + json.dumps(payload, ensure_ascii=False)) + + config.history_summary_status_sink = emit_status + self.context_manager = ContextManager(config, max_steps=info.agent_config.max_steps) + self.summary_model = SummaryModel(model) + self.context_timeout = 6 * model.profile.connection.timeout_seconds + info.mcp_close_timeout_seconds + self.executions = [] + self.last_evidence = None + base = list(items) + # Direct SDK callers have no database snapshot; preserve their completed turns. + if info.context_input is None: + pending = None + for index, entry in enumerate(info.history or ()): + if entry.role == "user": + pending = (index, entry.content) + elif entry.role == "assistant" and pending is not None: + base.append(ContextItem.from_input(ContextItemInput( + id=f"sdk-history:{index}", type="conversation_turn", content={ + "user_message": pending[1], "assistant_final_answer": entry.content, + "user_message_id": -(pending[0] + 1), "assistant_message_id": -(index + 1), + }, metadata={"layout_order": index}, + ))) + pending = None + self.run_context = self.context_manager.prepare_items_context("", items=base) + + def capacity_metadata(self): + """Use the same CM fields exposed by Nexent's ManagedContextRuntime, even in passthrough mode.""" + manager = self.context_manager + return ContextCapacityObservation( + context_window_tokens=manager.config.context_window_tokens or None, + token_threshold=manager.config.token_threshold or None, + effective_input_limit_tokens=manager.effective_input_limit_tokens or None, + context_processing_mode=manager.processing_mode, + ) + + def _assemble(self, messages, tools): + if self.scope.cancelled: + raise RunTerminated() + values = [{"role": "user", "content": messages}] if isinstance(messages, str) else [ + _mapping(message) for message in messages + ] + current, task_seen = [], False + for index, value in enumerate(values): + if value["role"] in {"system", "developer"}: + content = value.get("content") or "" + if isinstance(content, list): + content = "\n".join(part if isinstance(part, str) else part.get("text", "") for part in content) + if content: + current.append(ContextItem.from_input(ContextItemInput( + id=f"jiuwen-system:{index}", type="system", + content={"text": content, "role": value["role"]}, + source=("jiuwen_native_instruction",), metadata={"layout_order": index}, + ))) + elif value["role"] == "user" and not task_seen: + task_seen = True + current.append(ContextItem.from_input(ContextItemInput( + id="jiuwen-current-task", type="current_task", content={"text": self.info.query}, + source=("current_user_request",), + ))) + else: + payload = {key: value[key] for key in ("role", "content", "tool_calls", "tool_call_id") + if key in value} + current.append(NativeActionItem(id=f"jiuwen-action:{index}", type="current_action", + content={"messages": [payload]}, metadata={"layout_order": index})) + if not task_seen: + raise ValueError("Jiuwen current task is missing") + definitions = [tool.model_dump() for tool in resource_view(messages, tools).tool_definitions] + context = self.context_manager.assemble_context_items( + model=self.summary_model, current_items=current, tools=definitions, run_context=self.run_context, + ) + if self.scope.cancelled: + raise RunTerminated() + self.last_evidence = context.evidence + summary = self.context_manager.consume_history_summary_event() + if summary: + self.info.observer.add_message(self.info.agent_config.name, ProcessType.HISTORY_SUMMARY, + json.dumps(summary, ensure_ascii=False)) + return context.messages + + async def prepare_messages(self, messages, tools): + if self.scope.cancelled: + raise RunTerminated() + deadline = time.monotonic() + self.context_timeout + if self.info.model_deadline is not None: + deadline = min(deadline, self.info.model_deadline) + timeout = max(0.0, deadline - time.monotonic()) + execution = self.manager.submit( + "model-tool-io", ManagedTaskSpec(task_name="jiuwen-context", owner=__name__, + run_id=self.summary_model.model.run_id, deadline_monotonic=deadline), + self._assemble, messages, tools, + ) + self.executions.append(execution) + try: + return await asyncio.wait_for(asyncio.wrap_future(execution.future), timeout) + except TimeoutError: + logger.warning("event=jiuwen_context_timeout timeout_seconds=%.3f phase=queue_or_execution " + "error_type=TimeoutError", timeout) + self.scope.cancel() + self.manager.cancel(execution.execution_id, reason="Jiuwen context deadline exceeded", wait_timeout=0, + mark_stuck_on_timeout=False) + raise + except asyncio.CancelledError: + self.manager.cancel(execution.execution_id, reason="Jiuwen context cancelled", wait_timeout=0, + mark_stuck_on_timeout=False) + raise + + def close(self): + for execution in self.executions: + if not execution.future.done(): + self.manager.cancel(execution.execution_id, reason="Jiuwen context cleanup", + wait_timeout=self.info.mcp_close_timeout_seconds, mark_stuck_on_timeout=True) diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_messages.py b/sdk/nexent/core/agents/core_adapters/jiuwen_messages.py new file mode 100644 index 0000000000..7ba7f7876c --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/jiuwen_messages.py @@ -0,0 +1,79 @@ +"""Translate Jiuwen messages into the public, non-executable resource view.""" + +import json +from copy import deepcopy + +from ...agent_runtime_support.contracts import ResourceItem, RuntimeResourceView +from ...model_service.contracts import ModelMessage, ToolCall, ToolDefinition +from .jiuwen_protocol import EXECUTION_TOOL, PROTOCOL_INSTRUCTION, JiuwenProtocolError + + +def _mapping(value): + return value.model_dump() if hasattr(value, "model_dump") else deepcopy(value) + + +def _parts(content): + if content is None: + return [] + if isinstance(content, str): + return [{"type": "text", "text": content}] + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, str): + parts.append({"type": "text", "text": part}) + elif isinstance(part, dict) and isinstance(part.get("type"), str): + parts.append(deepcopy(part)) + else: + raise JiuwenProtocolError("JIUWEN_MESSAGE_CONTENT_INVALID") + return parts + raise JiuwenProtocolError("JIUWEN_MESSAGE_CONTENT_INVALID") + + +def resource_view(messages, tools, *, prefix=(), text_protocol=False): + """Keep ordering, actual call IDs and authorized definitions; copy caller data.""" + current = [{"role": "user", "content": messages}] if isinstance(messages, str) else [_mapping(m) for m in messages] + boundary = 0 + while boundary < len(current) and current[boundary]["role"] in {"system", "developer"}: + boundary += 1 + current[boundary:boundary] = [_mapping(message) for message in prefix] + if text_protocol: + current.insert(0, {"role": "system", "content": PROTOCOL_INSTRUCTION}) + roles = {"system": "application_instruction", "developer": "application_instruction", "user": "user_input", + "assistant": "assistant_history", "tool": "tool_result"} + items = [] + for index, value in enumerate(current): + if value.get("role") not in roles: + raise JiuwenProtocolError("JIUWEN_MESSAGE_ROLE_UNSUPPORTED") + calls = [] + for position, raw in enumerate(value.get("tool_calls") or []): + call = _mapping(raw) + function = call.get("function", call) + arguments = function.get("arguments", "") + if not isinstance(arguments, str): + arguments = json.dumps(arguments, ensure_ascii=False, allow_nan=False) + if not call.get("id"): + raise JiuwenProtocolError("JIUWEN_TOOL_CALL_ID_MISSING") + calls.append(ToolCall(provider_call_id=call["id"], index=position, name=function["name"], + raw_arguments=arguments)) + role = roles[value["role"]] + message = ModelMessage(semantic_role=role, content_parts=_parts(value.get("content")), tool_calls=calls, + tool_call_id=value.get("tool_call_id")) + items.append(ResourceItem(message=message, source="application" if role == "application_instruction" + else "conversation", order=index)) + user = next((item for item in reversed(items) if item.message.semantic_role == "user_input"), None) + if user is None: + raise JiuwenProtocolError("JIUWEN_CURRENT_INPUT_MISSING") + definitions = [] + for tool in tools or []: + data = _mapping(tool) + function = data.get("function", data) + if function["name"] == EXECUTION_TOOL: + continue + definitions.append(ToolDefinition(name=function["name"], description=function.get("description") or "", + input_schema=function.get("parameters") or {})) + return RuntimeResourceView( + current_user_input=user, tool_definitions=definitions, + instructions=[item for item in items if item.message.semantic_role == "application_instruction"], + history=[item for item in items if item is not user and item.message.semantic_role != "application_instruction"], + ) diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_model.py b/sdk/nexent/core/agents/core_adapters/jiuwen_model.py new file mode 100644 index 0000000000..1bd2fbdb4e --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/jiuwen_model.py @@ -0,0 +1,276 @@ +"""Jiuwen's model interface over Nexent's single model transport service.""" + +import json +import time +import uuid +from contextlib import aclosing +from copy import deepcopy +from types import SimpleNamespace + +from jsonschema import ValidationError, validate +from openjiuwen.core.foundation.llm import Model +from openjiuwen.core.foundation.llm.schema.config import ModelRequestConfig +from openjiuwen.core.foundation.llm.schema.message import AssistantMessage, UsageMetadata +from openjiuwen.core.foundation.llm.schema.message_chunk import AssistantMessageChunk +from openjiuwen.core.foundation.llm.schema.tool_call import ToolCall as NativeToolCall + +from ...concurrency.cancellation import RunTerminated +from ...model_service.contracts import ModelError +from ...model_service.profiles.resolved_profile import ResolvedModelProfile +from ...model_service.resource_conversion import ResourceConverterRegistry +from ...model_service.service import ModelServiceError +from .jiuwen_messages import resource_view +from .jiuwen_protocol import EXECUTION_TOOL, JiuwenProtocolError, parse_execution_blocks + + +_ARGUMENT_ERRORS = frozenset({ + "INVALID_TOOL_ARGUMENTS", "JIUWEN_NESTED_EXECUTION_BLOCK", + "JIUWEN_INVALID_EXECUTION_BLOCK", "JIUWEN_EMPTY_EXECUTION_BLOCK", +}) + + +def _text(parts): + if any(part.get("type") != "text" or not isinstance(part.get("text"), str) for part in parts): + raise JiuwenProtocolError("JIUWEN_RESPONSE_CONTENT_UNSUPPORTED") + return "".join(part["text"] for part in parts) + + +class JiuwenServiceModel(Model): + """Own one detached outlet; never construct or invoke a provider client.""" + + def __init__(self, service, profile, scope, *, prefix=(), run_id=None, deadline=None, + on_step=None, on_event=None, validate_input=None, prepare_messages=None, + on_error=None, on_recovery=None): + # Model's public invoke/stream seam is used without its provider-client constructor. + self.service = service + self.profile = ResolvedModelProfile.model_validate(profile.model_dump()) + self.scope = scope + self.prefix = deepcopy(prefix) + self.run_id = run_id + self.deadline = deadline + self.on_step, self.on_event, self.validate_input = on_step, on_event, validate_input + self.model_config = ModelRequestConfig(model=self.profile.identity.request_model_id) + self.model_client_config = None + self._client = None + self.text_protocol = self.profile.capabilities.tools.status != "supported" + self.converter = ResourceConverterRegistry().resolve(self.profile.bindings.native_converter) + self.prepare_messages = prepare_messages + self.on_error = on_error + self.last_usage = None + self.last_message = None + self.on_recovery = on_recovery + self.argument_corrections = 0 + self.response_corrections = 0 + self.logical_requests = 0 + + def _check_cancelled(self): + if self.scope.cancelled: + raise RunTerminated() + if self.deadline is not None and time.monotonic() >= self.deadline: + raise ModelServiceError(ModelError(category="MODEL_RUN_DEADLINE", scope="transport", + retryable=False, safe_message="Model deadline exceeded", request_id=self.run_id or "jiuwen")) + + def _request(self, messages, tools, streaming, kwargs): + self._check_cancelled() + # Harness routing hints are accepted, but generation settings cannot override the snapshot. + allowed = {"model", "temperature", "top_p", "max_tokens", "stop", "output_parser", "timeout"} + if set(kwargs) - allowed: + raise JiuwenProtocolError("JIUWEN_UNSUPPORTED_MODEL_OPTIONS") + for key in allowed - {"model"}: + if kwargs.get(key) is not None: + raise JiuwenProtocolError("JIUWEN_RUNTIME_PARAMETER_OVERRIDE") + if kwargs.get("model") not in (None, "", self.profile.identity.request_model_id): + raise JiuwenProtocolError("JIUWEN_MODEL_IDENTITY_MISMATCH") + view = resource_view(messages, tools, prefix=self.prefix, text_protocol=self.text_protocol) + if self.validate_input is not None: + self.validate_input(view) + self.logical_requests += 1 + request = self.converter.convert(view, self.profile, stream=streaming, run_id=self.run_id).request + if self.on_step is not None: + self.on_step(request) + return request + + def _fail(self, request, category): + error = ModelError( + category=category, scope="protocol", retryable=False, safe_message=category, + request_id=request.request_id, + ) + raise ModelServiceError(error) + + def _completion(self, message, request, definitions): + self._check_cancelled() + if message.refusal is not None: + self._fail(request, "MODEL_RESPONSE_REFUSED") + if message.finish_reason not in {"stop", "tool_calls"}: + self._fail(request, "MODEL_RESPONSE_INCOMPLETE" if message.finish_reason == "length" + else "MODEL_FINISH_REASON_UNSUPPORTED") + try: + content, reasoning = _text(message.content_parts), _text(message.reasoning_parts) + except JiuwenProtocolError as error: + self._fail(request, str(error)) + if self.text_protocol and message.tool_calls: + self._fail(request, "JIUWEN_UNEXPECTED_NATIVE_CALL") + if self.text_protocol and message.finish_reason != "stop": + self._fail(request, "JIUWEN_TOOL_FINISH_MISMATCH") + if not self.text_protocol and bool(message.tool_calls) != (message.finish_reason == "tool_calls"): + self._fail(request, "JIUWEN_TOOL_FINISH_MISMATCH") + calls = [] + schemas = {tool.name: tool.input_schema for tool in definitions} + for call in message.tool_calls: + if call.name not in schemas or call.name == EXECUTION_TOOL: + self._fail(request, "JIUWEN_UNAUTHORIZED_TOOL") + if call.argument_error or not isinstance(call.parsed_arguments, dict): + self._fail(request, "INVALID_TOOL_ARGUMENTS") + try: + validate(call.parsed_arguments, schemas[call.name]) + except ValidationError: + self._fail(request, "INVALID_TOOL_ARGUMENTS") + calls.append(NativeToolCall(id=call.provider_call_id, type="function", name=call.name, + arguments=call.raw_arguments, index=call.index)) + if self.text_protocol: + try: + block = parse_execution_blocks(content) + except JiuwenProtocolError as error: + self._fail(request, str(error)) + if block is not None: + calls.append(NativeToolCall(id="text_" + uuid.uuid4().hex, type="function", name=EXECUTION_TOOL, + arguments=json.dumps({"code": block.code}), index=0)) + if not calls and not content.strip(): + self._fail(request, "MODEL_EMPTY_RESPONSE") + self.last_message, self.last_usage = message, message.usage + usage = message.usage + native_usage = None + if usage.origin == "provider" and all(value is not None for value in ( + usage.input_tokens, usage.output_tokens, usage.total_tokens, + )): + native_usage = UsageMetadata(input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, + total_tokens=usage.total_tokens) + return AssistantMessage(content=content, reasoning_content=reasoning or None, + tool_calls=calls or None, finish_reason="tool_calls" if calls else "stop", + usage_metadata=native_usage) + + async def _invoke_once(self, messages, *, tools=None, **kwargs): + if self.prepare_messages is not None: + messages = await self.prepare_messages(messages, tools) + request = self._request(messages, tools, False, kwargs) + message = await self.service.ainvoke(request, self.profile, scope=self.scope, deadline=self.deadline) + definitions = resource_view(messages, tools).tool_definitions + native = self._completion(message, request, definitions) + if self.on_event is not None: + self.on_event(SimpleNamespace(type="message_completed", message=message)) + return native + + async def _stream_once(self, messages, *, tools=None, **kwargs): + if self.prepare_messages is not None: + messages = await self.prepare_messages(messages, tools) + request = self._request(messages, tools, True, kwargs) + definitions = resource_view(messages, tools).tool_definitions + emitted_text, emitted_reasoning = [], [] + completed = False + try: + async with self.service.astream(request, self.profile, scope=self.scope, deadline=self.deadline, + empty_response_policy="fail") as events: + async for event in events: + self._check_cancelled() + if completed: + self._fail(request, "MODEL_DUPLICATE_COMPLETION") + if self.on_event is not None and event.type not in {"message_completed", "error"}: + self.on_event(event) + if event.type == "error": + raise ModelServiceError(event.error) + if event.type == "text_delta": + if event.content_kind == "refusal": + self._fail(request, "MODEL_RESPONSE_REFUSED") + emitted_text.append(event.delta) + yield AssistantMessageChunk(content=event.delta) + elif event.type == "reasoning_delta": + emitted_reasoning.append(event.delta) + yield AssistantMessageChunk(content="", reasoning_content=event.delta) + elif event.type == "message_completed": + native = self._completion(event.message, request, definitions) + if ((emitted_text and "".join(emitted_text) != native.content) + or (emitted_reasoning and "".join(emitted_reasoning) + != (native.reasoning_content or ""))): + self._fail(request, "MODEL_STREAM_COMPLETION_MISMATCH") + if self.on_event is not None: + self.on_event(event) + completed = True + # Only complete validated calls are exposed; never forward tool_call_delta to execution. + yield AssistantMessageChunk( + content="" if emitted_text else native.content, + reasoning_content=None if emitted_reasoning else native.reasoning_content, + tool_calls=native.tool_calls, finish_reason=native.finish_reason, + usage_metadata=native.usage_metadata, + ) + except ModelServiceError: + raise + if not completed: + self._fail(request, "MODEL_STREAM_INCOMPLETE") + + + def _correct(self, messages, error, step_corrections): + """Retry only validated model completions, never transports or executed actions.""" + try: + self._check_cancelled() + except ModelServiceError as expired: + if self.on_error is not None: + self.on_error(expired.error) + raise + if self.on_error is not None: + self.on_error(error) + category = error.category + if category in _ARGUMENT_ERRORS: + if step_corrections or self.argument_corrections >= 3: + return None + self.argument_corrections += 1 + kind = "arguments" + elif category in {"MODEL_EMPTY_RESPONSE", "MODEL_RESPONSE_INCOMPLETE"}: + if self.response_corrections >= 1: + return None + self.response_corrections += 1 + kind = "response" + else: + return None + # Invalid calls never enter native history. Reuse only the authoritative, pre-step messages. + current = [{"role": "user", "content": messages}] if isinstance(messages, str) else deepcopy(messages) + current.insert(0, {"role": "system", "content": ( + "The previous model response was rejected before any action was dispatched: " + category + ". " + "Regenerate this model step with a complete answer or a complete authorized call matching its schema. " + "Use the existing tool observations; do not repeat completed actions." + )}) + if self.on_recovery is not None: + self.on_recovery({"kind": kind, "category": category, "request_id": str(error.request_id), + "logical_request": self.logical_requests, "argument_corrections": self.argument_corrections, + "response_corrections": self.response_corrections}) + return current + + async def invoke(self, messages, *, tools=None, **kwargs): + step_corrections = 0 + while True: + try: + return await self._invoke_once(messages, tools=tools, **kwargs) + except ModelServiceError as failure: + corrected = self._correct(messages, failure.error, step_corrections) + if corrected is None: + raise + step_corrections += failure.error.category in _ARGUMENT_ERRORS + messages = corrected + + async def stream(self, messages, *, tools=None, **kwargs): + step_corrections = 0 + while True: + try: + # Public source events remain live; rejected chunks never enter native history. + chunks = [] + async with aclosing(self._stream_once(messages, tools=tools, **kwargs)) as stream: + async for chunk in stream: + chunks.append(chunk) + for chunk in chunks: + yield chunk + return + except ModelServiceError as failure: + corrected = self._correct(messages, failure.error, step_corrections) + if corrected is None: + raise + step_corrections += failure.error.category in _ARGUMENT_ERRORS + messages = corrected diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py b/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py new file mode 100644 index 0000000000..fddbab6fc1 --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py @@ -0,0 +1,58 @@ +"""Strict recognition of the existing, explicitly executable text protocol.""" + +import ast +import re +from dataclasses import dataclass + + +EXECUTION_TOOL = "nexent_execute_block" +PROTOCOL_INSTRUCTION = ( + "This outlet uses the controlled text execution protocol, not native function calls. " + "Only complete ... blocks request Python execution with the listed authorized tools. " + "For each action step emit one complete, non-nested ... block with only Python inside. " + "Never put code tags inside a code block, repeat opening tags, or wrap executable tags in Markdown fences. " + "Ordinary prose and fenced Python examples are not executable. " + "After execution, use the returned observation to continue or answer. " + "Do not combine an executable block with a final_answer envelope. " + "When finished, answer in ordinary text without code tags or a final_answer envelope." +) + + +class JiuwenProtocolError(ValueError): + """Expose a stable code, never the rejected model content or arguments.""" + + +@dataclass(frozen=True) +class TextExecution: + code: str + + +def parse_execution_blocks(content: str) -> TextExecution | None: + """Accept complete code/run blocks; reject ambiguous or partial control syntax.""" + has_code = "" in content or "" in content + has_run = "```" in content + if not has_code and not has_run: + return None + if has_code and has_run or "" in content or "" in content: + raise JiuwenProtocolError("JIUWEN_AMBIGUOUS_EXECUTION_BLOCK") + pattern = r"(.*?)" if has_code else r"```\s*(.*?)```" + matches = list(re.finditer(pattern, content, re.DOTALL)) + if not matches: + raise JiuwenProtocolError("JIUWEN_INCOMPLETE_EXECUTION_BLOCK") + # Examples stay non-executable even when they contain malformed nested tags. + for match in matches: + if content[:match.start()].count("```") % 2: + raise JiuwenProtocolError("JIUWEN_AMBIGUOUS_EXECUTION_BLOCK") + if any(any(marker in match.group(1) for marker in ("", "", "```")) for match in matches): + raise JiuwenProtocolError("JIUWEN_NESTED_EXECUTION_BLOCK") + remainder = re.sub(pattern, "", content, flags=re.DOTALL) + if any(marker in remainder for marker in ("", "", "```")): + raise JiuwenProtocolError("JIUWEN_INCOMPLETE_EXECUTION_BLOCK") + code = "\n\n".join(match.group(1).strip() for match in matches) + if not code: + raise JiuwenProtocolError("JIUWEN_EMPTY_EXECUTION_BLOCK") + try: + ast.parse(code, mode="exec") + except (SyntaxError, ValueError, RecursionError): + raise JiuwenProtocolError("JIUWEN_INVALID_EXECUTION_BLOCK") from None + return TextExecution(code) diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_resources.py b/sdk/nexent/core/agents/core_adapters/jiuwen_resources.py new file mode 100644 index 0000000000..1191f30f71 --- /dev/null +++ b/sdk/nexent/core/agents/core_adapters/jiuwen_resources.py @@ -0,0 +1,71 @@ +"""Expose authorized Jiuwen abilities to the existing controlled Python executor.""" + +import asyncio +from concurrent.futures import CancelledError +from copy import deepcopy + +from jsonschema import validate + +from ...concurrency.cancellation import RunTerminated + + +class JiuwenResourceBridge: + """Share native execution entries without creating a second resource dispatcher. + + Python executes in the managed sandbox lane. Resource coroutines return to + their owning run loop and retain the existing model-tool-io/MCP ownership. + No resource worker waits for another worker in its own bounded lane. + """ + + def __init__(self, agent, loop, scope, visible_names): + self.agent, self.loop, self.scope = agent, loop, scope + self.visible_names = frozenset(visible_names) + + def bindings(self): + from openjiuwen.core.runner import Runner + + result = {} + for card in self.agent.ability_manager.list(): + if card.name not in self.visible_names: + continue + tool = Runner.resource_mgr.get_tool(card.id) + if tool is None: + raise RuntimeError("Authorized resource is unavailable") + result[card.name] = self._binding(tool, deepcopy(card.input_params)) + return result + + def _binding(self, tool, schema): + names = list(schema.get("properties", {})) + + def call(*args, **arguments): + if self.scope.cancelled: + raise RunTerminated() + if len(args) > len(names) or any(key in arguments for key in names[:len(args)]): + raise ValueError("Invalid positional resource arguments") + arguments = {**dict(zip(names, args)), **arguments} + validate(arguments, schema) + # Identity and other undeclared host arguments cannot enter through code kwargs. + if set(arguments) - set(names) and schema.get("additionalProperties") is not True: + raise ValueError("Undeclared resource arguments") + + async def invoke(): + if self.scope.cancelled: + raise asyncio.CancelledError() + return await tool.invoke(arguments) + + coroutine = invoke() + try: + future = asyncio.run_coroutine_threadsafe(coroutine, self.loop) + except BaseException: + coroutine.close() + raise + token = self.scope.register_closer(future.cancel) + try: + return future.result() + except CancelledError: + raise RunTerminated() from None + finally: + self.scope.unregister_closer(token) + + call._nexent_execute_on_host = True + return call diff --git a/sdk/nexent/core/agents/core_adapters/mcp.py b/sdk/nexent/core/agents/core_adapters/mcp.py index 9c55e36179..c79c7a67b6 100644 --- a/sdk/nexent/core/agents/core_adapters/mcp.py +++ b/sdk/nexent/core/agents/core_adapters/mcp.py @@ -3,6 +3,7 @@ import asyncio import logging from contextlib import AsyncExitStack, nullcontext +from copy import deepcopy from types import SimpleNamespace from openjiuwen.core.foundation.tool.mcp.base import McpServerConfig, MCPTool, McpToolCard @@ -26,7 +27,7 @@ def __init__(self, *, authorized_tools, observe, user_context=None, emit_result= self.observe = observe self.emit_result = emit_result self.terminal_error = None - self.user_context = dict(user_context or {}) + self.user_context = deepcopy(dict(user_context or {})) async def _run_async_session(self): from ..run_agent import _normalize_mcp_config @@ -61,7 +62,7 @@ async def _run_async_session(self): client, remote_card = match properties = remote_card.input_params.get("properties", {}) hidden = [name for name in USER_CONTEXT_FIELDS if name in properties] - proxy = _ManagedNativeClient(self, client, remote_card.name, hidden) + proxy = _ManagedNativeClient(self, client, remote_card.name, hidden, tool_config.params) card = remote_card.model_copy(deep=True) for name in hidden: card.input_params["properties"].pop(name, None) @@ -84,15 +85,16 @@ async def _run_async_session(self): class _ManagedNativeClient: - def __init__(self, owner, client, remote_name, hidden_fields=()): + def __init__(self, owner, client, remote_name, hidden_fields=(), configured_arguments=None): self.owner, self.client, self.remote_name = owner, client, remote_name self.hidden_fields = hidden_fields + self.configured_arguments = deepcopy(dict(configured_arguments or {})) async def call_tool(self, tool_name, arguments): owner = self.owner if owner.cancellation_scope.cancelled: raise asyncio.CancelledError() - arguments = dict(arguments) + arguments = {**arguments, **deepcopy(self.configured_arguments)} for name in self.hidden_fields: arguments[name] = owner.user_context.get(name) execution = owner.manager.submit( diff --git a/sdk/nexent/core/agents/core_adapters/skills.py b/sdk/nexent/core/agents/core_adapters/skills.py index 967a83991f..7b0d900562 100644 --- a/sdk/nexent/core/agents/core_adapters/skills.py +++ b/sdk/nexent/core/agents/core_adapters/skills.py @@ -8,10 +8,14 @@ class NexentSkillRail(SkillUseRail): """Keep native skill discovery/prompt lifecycle without adding host shell tools.""" - def __init__(self, directories: list[str], names: list[str]): + def __init__(self, directories: list[str], names: list[str], *, authorized_by_root=None): super().__init__(directories, enabled_skills=names, skill_mode="all", include_tools=False, enable_cache=False) self._roots = tuple(Path(directory).resolve() for directory in directories) self._names = frozenset(names) + authorization = authorized_by_root if authorized_by_root is not None else { + directory: names for directory in directories + } + self._authorized_by_root = {Path(root).resolve(): frozenset(allowed) for root, allowed in authorization.items()} def init(self, agent): # Authorized Nexent read/run handlers are already native abilities. @@ -23,7 +27,8 @@ async def _read_skill_text(self, path: Path, *, head=None): path = path.resolve() if path.name != "SKILL.md" or path.parent.name not in self._names: raise PermissionError("Skill is outside the authorized snapshot") - if not any(path.is_relative_to(root) for root in self._roots): + if not any(path.is_relative_to(root) and path.parent.name in allowed + for root, allowed in self._authorized_by_root.items()): raise PermissionError("Skill path escapes the authorized snapshot") text = path.read_text(encoding="utf-8-sig") return "\n".join(text.splitlines()[:head]) if head is not None else text diff --git a/sdk/nexent/core/agents/core_adapters/smolagents.py b/sdk/nexent/core/agents/core_adapters/smolagents.py index 9e08520e37..40e7d31331 100644 --- a/sdk/nexent/core/agents/core_adapters/smolagents.py +++ b/sdk/nexent/core/agents/core_adapters/smolagents.py @@ -1,6 +1,6 @@ """Compatibility adapter for the existing smolagents execution path.""" -from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig +from .base import AgentCoreAdapter class SmolagentsAdapter(AgentCoreAdapter): @@ -15,17 +15,11 @@ def capabilities(cls): return frozenset({"tools", "mcp", "skills", "workers", "a2a", "planning", "verification"}) def validate(self): - def validate_tree(config): - for child in getattr(config, "worker_agents", ()): - if getattr(child, "agent_core", "smolagents") != self.core_name: - raise UnsupportedAgentCoreConfig("Mixed-core worker agents are not supported") - validate_tree(child) - - validate_tree(self.run_info.agent_config) + """Children validate through their selected adapter at invocation time.""" def run(self): if self.executor is not None: return self.executor(self.run_info) from ..run_agent import _run_smolagents - _run_smolagents(self.run_info) + return _run_smolagents(self.run_info) diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index 1ec951f424..a1cddaaba2 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -277,6 +277,7 @@ def __init__(self, observer: MessageObserver, if not isinstance(observer, MessageObserver): raise TypeError("Create Observer Object with MessageObserver") + self.core_run_info = None self.observer = observer self.model_config_list = model_config_list self.stop_event = stop_event @@ -782,6 +783,13 @@ def _subagent_invocation(self, config: AgentConfig, definition: Any, tree_contex parent_token = self.cancellation_scope.register_closer(scope.cancel) if scope.cancelled or self.stop_event.is_set(): raise RunTerminated("Subagent invocation cancelled before allocation") + from .core_adapters.children import child_run_info, invoke_child + + if getattr(self, "core_run_info", None) is not None: + info = child_run_info(self.core_run_info, config, scope) + yield lambda task, **kwargs: invoke_child(info, task) + drained = True + return runtime = type(self)( observer=self.observer, model_config_list=self.model_config_list, @@ -1561,6 +1569,7 @@ def agent_run_with_observer( if hasattr(self.agent, "step_metrics"): monitoring_manager.set_agent_context_metrics(self.agent.step_metrics) monitoring_manager.set_openinference_output(final_answer_for_trace) + return final_answer_for_trace def _record_workspace_upload(self, upload: Dict[str, Any]) -> None: """Collect one upload result for the frontend artifact event.""" diff --git a/sdk/nexent/core/agents/run_agent.py b/sdk/nexent/core/agents/run_agent.py index 4f100f8076..cdb7537473 100644 --- a/sdk/nexent/core/agents/run_agent.py +++ b/sdk/nexent/core/agents/run_agent.py @@ -366,6 +366,8 @@ def agent_run_thread(agent_run_info: AgentRunInfo): def _agent_run_thread(agent_run_info: AgentRunInfo): mcp_host = getattr(agent_run_info, "mcp_host", None) + if hasattr(agent_run_info, "run_result"): + agent_run_info.run_result = None try: # Keep the runner compatible with legacy/lightweight AgentRunInfo # stand-ins used by integrations. Full AgentRunInfo instances always @@ -389,7 +391,11 @@ def _agent_run_thread(agent_run_info: AgentRunInfo): finally: adapter.close() - agent_run_info.attempt_outcome = "stopped" if agent_run_info.stop_event.is_set() else "completed" + from ..agent_runtime_support.output import RunResult + + result = getattr(agent_run_info, "run_result", None) + agent_run_info.attempt_outcome = result.status if isinstance(result, RunResult) else ( + "stopped" if agent_run_info.stop_event.is_set() else "completed") except (RunTerminated, CancelledError): agent_run_info.attempt_outcome = "stopped" except (ModelInvocationTerminalError, ModelOutputProtocolExhaustedError): @@ -397,11 +403,10 @@ def _agent_run_thread(agent_run_info: AgentRunInfo): raise except Exception as e: agent_run_info.attempt_outcome = "failed" - agent_run_info.observer.add_message( - "", - ProcessType.FINAL_ANSWER, - _agent_run_error_message(agent_run_info, mcp_host, e), - ) + if getattr(agent_run_info, "run_result", None) is None: + agent_run_info.observer.add_message( + "", ProcessType.FINAL_ANSWER, _agent_run_error_message(agent_run_info, mcp_host, e), + ) raise ValueError(f"Error in agent_run_thread: {e}") finally: # Agent construction, MCP setup, and executor initialization can fail @@ -437,6 +442,7 @@ def _run_smolagents(agent_run_info: AgentRunInfo): minio_files=getattr(agent_run_info, "minio_files", None), cancellation_scope=agent_run_info.cancellation_scope, ) + nexent.core_run_info = agent_run_info agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. agent_run_info.agent_config, context_items_override=_get_authorized_context_items(agent_run_info), @@ -445,7 +451,7 @@ def _run_smolagents(agent_run_info: AgentRunInfo): nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) try: - nexent.agent_run_with_observer( + return nexent.agent_run_with_observer( query=agent_run_info.query, reset=False, additional_args=build_run_additional_args(agent_run_info), @@ -486,6 +492,7 @@ def _run_smolagents(agent_run_info: AgentRunInfo): minio_files=getattr(agent_run_info, "minio_files", None), cancellation_scope=agent_run_info.cancellation_scope, ) + nexent.core_run_info = agent_run_info agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. agent_run_info.agent_config, context_items_override=_get_authorized_context_items(agent_run_info), @@ -494,7 +501,7 @@ def _run_smolagents(agent_run_info: AgentRunInfo): nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) try: - nexent.agent_run_with_observer( + return nexent.agent_run_with_observer( query=agent_run_info.query, reset=False, additional_args=build_run_additional_args(agent_run_info), diff --git a/sdk/nexent/core/model_service/__init__.py b/sdk/nexent/core/model_service/__init__.py new file mode 100644 index 0000000000..8bbf7e4f38 --- /dev/null +++ b/sdk/nexent/core/model_service/__init__.py @@ -0,0 +1 @@ +"""Model service contracts and provider adapters.""" diff --git a/sdk/nexent/core/model_service/adapters/__init__.py b/sdk/nexent/core/model_service/adapters/__init__.py new file mode 100644 index 0000000000..8be32bf576 --- /dev/null +++ b/sdk/nexent/core/model_service/adapters/__init__.py @@ -0,0 +1 @@ +"""Registered provider request and response implementations.""" diff --git a/sdk/nexent/core/model_service/adapters/openai_chat.py b/sdk/nexent/core/model_service/adapters/openai_chat.py new file mode 100644 index 0000000000..ea1a45ea83 --- /dev/null +++ b/sdk/nexent/core/model_service/adapters/openai_chat.py @@ -0,0 +1,305 @@ +"""OpenAI-compatible chat framing; runtime execution and retries stay outside.""" + +import json +import math + +from pydantic import ValidationError + +from nexent.core.model_service.contracts import AssistantMessage, ModelRequest, TokenUsage, ToolCall +from nexent.core.model_service.http_contracts import PreparedHttpRequest, RawHttpResponse +from nexent.core.model_service.parameter_transforms import parameter_transform_references, transform_parameter +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules, validate_saved_parameter_values +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.request_parameters import _set, _tokens, apply_extra_api_parameters +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + +class ModelAdapterError(ValueError): + """Fixed protocol category without model text, tool arguments or raw body.""" + + def __init__(self, category: str, *, response: RawHttpResponse | None = None): + self.category = category + self.response = response + super().__init__(category) + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate JSON member") + result[key] = value + return result + + +def _invalid_constant(value): + raise ValueError("Invalid JSON constant") + + +def _finite_float(value): + number = float(value) + if not math.isfinite(number): + raise ValueError("Invalid JSON number") + return number + + +def parse_json(text: str): + return json.loads( + text, object_pairs_hook=_unique_object, parse_constant=_invalid_constant, parse_float=_finite_float + ) + + +def _content_parts(value): + if value is None: + return [] + if isinstance(value, str): + return [{"type": "text", "text": value}] + if isinstance(value, list) and all(isinstance(part, dict) and isinstance(part.get("type"), str) for part in value): + return value + raise ModelAdapterError("MODEL_RESPONSE_INVALID") + + +def _usage(raw, semantics_id): + if raw is None: + return TokenUsage(semantics_id=semantics_id) + if not isinstance(raw, dict): + raise ModelAdapterError("MODEL_USAGE_INVALID") + details = [] + for key in ("prompt_tokens_details", "completion_tokens_details"): + value = raw.get(key) + if value is None: + value = {} + if not isinstance(value, dict): + raise ModelAdapterError("MODEL_USAGE_INVALID") + details.append(value) + try: + return TokenUsage( + input_tokens=raw.get("prompt_tokens"), + output_tokens=raw.get("completion_tokens"), + total_tokens=raw.get("total_tokens"), + cached_input_tokens=details[0].get("cached_tokens"), + reasoning_tokens=details[1].get("reasoning_tokens"), + origin="provider", + semantics_id=semantics_id, + ) + except ValidationError: + raise ModelAdapterError("MODEL_USAGE_INVALID") from None + + +def _tool_call(raw, index): + if not isinstance(raw, dict) or raw.get("type") != "function" or not isinstance(raw.get("function"), dict): + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") + function = raw["function"] + arguments = function.get("arguments") + if not isinstance(arguments, str): + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") + parsed, error = None, None + try: + parsed = parse_json(arguments) + if not isinstance(parsed, dict): + raise ValueError("Tool arguments must be an object") + except (ValueError, RecursionError): + parsed, error = None, "INVALID_TOOL_ARGUMENTS" + try: + return ToolCall( + provider_call_id=raw.get("id"), + index=index, + name=function.get("name"), + raw_arguments=arguments, + parsed_arguments=parsed, + argument_error=error, + ) + except ValidationError: + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") from None + + +class OpenAIChatAdapter: + """Single-choice messages with declared parameter bindings and wire metadata.""" + + reference = VersionedReference(ref="openai_chat_v1", version=1) + protocol_reference = VersionedReference(ref="openai_chat_completions", version=1) + + def __init__(self, *, reasoning_replay: bool = False): + if type(reasoning_replay) is not bool: + raise ValueError("Reasoning replay declaration must be boolean") + self._reasoning_replay = reasoning_replay + + def prepare_http_request(self, request: ModelRequest, profile: ResolvedModelProfile) -> PreparedHttpRequest: + if request.outlet_id != profile.outlet_id or request.config_revision != profile.config_revision: + raise ModelAdapterError("MODEL_SNAPSHOT_MISMATCH") + if profile.bindings.protocol != self.protocol_reference: + raise ModelAdapterError("MODEL_PROTOCOL_UNSUPPORTED") + validate_instruction_mapping(profile.instruction_mapping) + if request.tools and profile.capabilities.tools.status != "supported": + raise ModelAdapterError("NATIVE_TOOL_PROTOCOL_UNAVAILABLE") + mappings = profile.instruction_mapping.mappings.model_dump() + messages = [] + for message in request.messages: + role = mappings.get(message.semantic_role, message.semantic_role) + if role not in profile.instruction_mapping.supported_roles: + raise ModelAdapterError("MODEL_ROLE_UNSUPPORTED") + if message.tool_calls and role != "assistant" or message.tool_call_id is not None and role != "tool": + raise ModelAdapterError("MODEL_MESSAGE_INVALID") + if role == "tool" and message.tool_call_id is None: + raise ModelAdapterError("MODEL_MESSAGE_INVALID") + if ( + message.tool_calls or message.tool_call_id is not None or role == "tool" + ) and profile.capabilities.tools.status != "supported": + raise ModelAdapterError("NATIVE_TOOL_PROTOCOL_UNAVAILABLE") + parts = message.content_parts + if any( + part.get("type") + not in {"text", "image_url", "input_audio", "audio_url", "video_url", "input_image", "input_video"} + for part in parts + ): + raise ModelAdapterError("MODEL_CONTENT_UNSUPPORTED") + if any(part.get("type") == "text" and not isinstance(part.get("text"), str) for part in parts): + raise ModelAdapterError("MODEL_CONTENT_UNSUPPORTED") + simple_text = len(parts) == 1 and set(parts[0]) == {"type", "text"} + wire = {"role": role, "content": parts[0]["text"] if simple_text else parts} + if not parts: + wire["content"] = None if role == "assistant" and message.tool_calls else "" + if message.tool_call_id is not None: + wire["tool_call_id"] = message.tool_call_id + if message.tool_calls: + wire["tool_calls"] = [ + { + "id": call.provider_call_id, + "type": "function", + "function": {"name": call.name, "arguments": call.raw_arguments}, + } + for call in message.tool_calls + ] + if message.reasoning_parts: + if ( + not self._reasoning_replay + or role != "assistant" + or any( + part.get("type") != "text" or not isinstance(part.get("text"), str) + for part in message.reasoning_parts + ) + ): + raise ModelAdapterError("MODEL_REASONING_FORMAT_UNSUPPORTED") + wire["reasoning_content"] = "".join(part["text"] for part in message.reasoning_parts) + messages.append(wire) + body = {"model": profile.identity.request_model_id, "messages": messages, "stream": request.stream} + if request.tools: + body["tools"] = [ + { + "type": "function", + "function": {"name": tool.name, "description": tool.description, "parameters": tool.input_schema}, + } + for tool in request.tools + ] + if request.stream: + body["stream_options"] = {"include_usage": True} + try: + rules = compile_parameter_rules(profile.parameter_rules) + validate_saved_parameter_values(rules, profile.parameter_values) + bindings = validate_parameter_bindings(rules, parameter_transform_references()) + for key, binding in bindings.items(): + if key in profile.parameter_values: + _set(body, _tokens(binding["path"]), transform_parameter(binding, profile.parameter_values[key]), + binding["path"]) + body = apply_extra_api_parameters( + body, profile.extra_api_parameters, parameter_paths=[binding["path"] for binding in bindings.values()] + ) + return PreparedHttpRequest( + request_id=request.request_id, + method=profile.connection_rules.method, + base_url=profile.connection.base_url, + relative_path=profile.connection_rules.relative_path, + headers=profile.connection_rules.headers, + credential_ref=profile.connection.credential_ref, + json_body=body, + timeout_seconds=profile.connection.timeout_seconds, + ssl_verify=profile.connection.ssl_verify, + stream_protocol="sse" if request.stream else None, + ) + except (ParameterValidationError, ValueError, TypeError): + raise ModelAdapterError("MODEL_REQUEST_CONFIGURATION_INVALID") from None + + def decode_response( + self, raw: RawHttpResponse, request: ModelRequest, profile: ResolvedModelProfile + ) -> AssistantMessage: + try: + body = parse_json(raw.body.decode("utf-8")) + if isinstance(body, dict) and body.get("error") is not None: + raise ModelAdapterError("MODEL_PROVIDER_ERROR", response=raw) + if not isinstance(body, dict) or not isinstance(body.get("choices"), list) or len(body["choices"]) != 1: + raise ModelAdapterError("MODEL_RESPONSE_INVALID") + choice = body["choices"][0] + if not isinstance(choice, dict) or type(choice.get("index")) is not int or choice["index"] != 0: + raise ModelAdapterError("MODEL_RESPONSE_INVALID") + message = choice.get("message") + if not isinstance(message, dict) or message.get("role") != "assistant": + raise ModelAdapterError("MODEL_RESPONSE_INVALID") + finish = choice.get("finish_reason") + if not isinstance(finish, str) or not finish.strip(): + raise ModelAdapterError("MODEL_RESPONSE_INCOMPLETE") + calls = message.get("tool_calls") + if calls is None: + calls = [] + if not isinstance(calls, list): + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") + parts = _content_parts(message.get("content")) + if message.get("audio") is not None: + parts.append({"type": "audio", "audio": message["audio"]}) + return AssistantMessage( + content_parts=parts, + reasoning_parts=_content_parts(message.get("reasoning_content")), + refusal=message.get("refusal"), + tool_calls=[_tool_call(call, index) for index, call in enumerate(calls)], + finish_reason=finish, + usage=_usage(body.get("usage"), profile.capabilities.usage.semantics_id), + provider_metadata={ + "response_id": body.get("id"), + "model": body.get("model"), + "request_id": request.request_id, + "unmapped_message_fields": { + key: value + for key, value in message.items() + if key not in {"role", "content", "reasoning_content", "refusal", "tool_calls", "audio"} + }, + }, + ) + except ModelAdapterError: + raise + except (ValueError, TypeError, UnicodeError, RecursionError): + raise ModelAdapterError("MODEL_RESPONSE_INVALID") from None + + def new_stream_decoder(self, request, profile): + """Import the stream state lazily to keep complete-message helpers reusable.""" + from nexent.core.model_service.adapters.openai_stream import OpenAIStreamDecoder + + return OpenAIStreamDecoder(request, profile) + + def normalize_error(self, error, *, request_id, attempt=1, max_attempts=1, will_retry=False): + from nexent.core.model_service.errors import normalize_model_error + + return normalize_model_error( + error, request_id=request_id, attempt=attempt, max_attempts=max_attempts, will_retry=will_retry + ) + + def decode_stream(self, raw_events, request, profile): + """Stop at the verified completion marker; HTTP ownership is external.""" + decoder = self.new_stream_decoder(request, profile) + for frame in raw_events: + for event in decoder.push(frame): + yield event + if event.type == "message_completed": + return + yield from decoder.finish() + + async def adecode_stream(self, raw_events, request, profile): + decoder = self.new_stream_decoder(request, profile) + async for frame in raw_events: + for event in decoder.push(frame): + yield event + if event.type == "message_completed": + return + for event in decoder.finish(): + yield event diff --git a/sdk/nexent/core/model_service/adapters/openai_stream.py b/sdk/nexent/core/model_service/adapters/openai_stream.py new file mode 100644 index 0000000000..537c25e2e8 --- /dev/null +++ b/sdk/nexent/core/model_service/adapters/openai_stream.py @@ -0,0 +1,235 @@ +"""One-message OpenAI-compatible stream state, independent of HTTP execution.""" + +import json +from uuid import uuid4 + +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError, OpenAIChatAdapter, _usage, parse_json +from nexent.core.model_service.contracts import ModelEvent, ModelRequest, ToolCallDelta +from nexent.core.model_service.http_contracts import RawHttpResponse +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.sse import SSEEvent + + +class OpenAIStreamDecoder: + """Accumulate complete calls only after a declared provider completion boundary.""" + + def __init__( + self, + request: ModelRequest, + profile: ResolvedModelProfile, + *, + require_done: bool = True, + max_message_bytes: int = 32 * 1024 * 1024, + max_tool_calls: int = 128, + ): + if type(require_done) is not bool or any( + type(value) is not int or value <= 0 for value in (max_message_bytes, max_tool_calls) + ): + raise ValueError("Invalid model stream limits or completion declaration") + if not request.stream: + raise ModelAdapterError("MODEL_STREAM_REQUEST_REQUIRED") + self._request, self._profile = request, profile + self._require_done = require_done + self._limit, self._tool_limit, self._size = max_message_bytes, max_tool_calls, 0 + self._message_id = uuid4() + self._sequence = 0 + self._closed = False + self._finish = None + self._identity = {} + self._content, self._reasoning, self._refusal = [], [], [] + self._calls = {} + self._usage_raw = None + self._unknown = [] + + def _fail(self, category): + self._closed = True + raise ModelAdapterError(category) + + def _event(self, kind, **payload): + self._sequence += 1 + return ModelEvent( + request_id=self._request.request_id, + message_id=self._message_id, + sequence=self._sequence, + type=kind, + **payload, + ) + + def _complete(self): + if self._finish is None: + self._fail("MODEL_RESPONSE_INCOMPLETE") + ordered = sorted(self._calls.items()) + message = { + "role": "assistant", + "content": "".join(self._content) if self._content else None, + "reasoning_content": "".join(self._reasoning) if self._reasoning else None, + "refusal": "".join(self._refusal) if self._refusal else None, + "tool_calls": [ + { + "id": call["id"], + "type": "function", + "function": {"name": "".join(call["name"]), "arguments": "".join(call["arguments"])}, + } + for _, call in ordered + ], + } + if self._finish == "tool_calls" and not ordered: + self._fail("MODEL_TOOL_CALL_INVALID") + body = { + **self._identity, + "choices": [{"index": 0, "message": message, "finish_reason": self._finish}], + "usage": self._usage_raw, + } + raw = RawHttpResponse(200, {}, json.dumps(body, ensure_ascii=False).encode()) + complete = OpenAIChatAdapter().decode_response(raw, self._request, self._profile) + calls = [ + call.model_copy(update={"index": index, "local_call_id": state["local_id"]}) + for call, (index, state) in zip(complete.tool_calls, ordered) + ] + complete = complete.model_copy( + update={ + "message_id": self._message_id, + "tool_calls": calls, + "provider_metadata": {**complete.provider_metadata, "unmapped_delta_fields": self._unknown}, + } + ) + self._closed = True + return [self._event("message_completed", message=complete, finish_reason=complete.finish_reason)] + + def push(self, frame: SSEEvent) -> list[ModelEvent]: + if self._closed: + raise ModelAdapterError("MODEL_STREAM_CLOSED") + try: + self._size += len(frame.data.encode("utf-8")) + if self._size > self._limit: + self._fail("MODEL_RESPONSE_TOO_LARGE") + if frame.data == "[DONE]": + return self._complete() + body = parse_json(frame.data) + if isinstance(body, dict) and body.get("error") is not None: + raise ModelAdapterError( + "MODEL_PROVIDER_ERROR", response=RawHttpResponse(200, {}, frame.data.encode("utf-8")) + ) + if not isinstance(body, dict) or not isinstance(body.get("choices"), list): + self._fail("MODEL_RESPONSE_INVALID") + for key in ("id", "model"): + if key in body: + value = body[key] + if ( + not isinstance(value, str) + or not value.strip() + or key in self._identity + and self._identity[key] != value + ): + self._fail("MODEL_STREAM_IDENTITY_CONFLICT") + self._identity[key] = value + events = [] + if body.get("usage") is not None: + usage = _usage(body["usage"], self._profile.capabilities.usage.semantics_id) + if self._usage_raw is not None: + if self._usage_raw != body["usage"]: + self._fail("MODEL_USAGE_CONFLICT") + else: + self._usage_raw = body["usage"] + events.append(self._event("usage", usage=usage)) + choices = body["choices"] + if not choices: + if body.get("usage") is None: + self._fail("MODEL_RESPONSE_INVALID") + return events + if len(choices) != 1: + self._fail("MODEL_RESPONSE_INVALID") + choice = choices[0] + if not isinstance(choice, dict) or type(choice.get("index")) is not int or choice["index"] != 0: + self._fail("MODEL_RESPONSE_INVALID") + delta = choice.get("delta") + if not isinstance(delta, dict) or delta.get("role", "assistant") != "assistant": + self._fail("MODEL_RESPONSE_INVALID") + if self._finish is not None: + self._fail("MODEL_STREAM_AFTER_FINISH") + for key, target, kind in ( + ("reasoning_content", self._reasoning, "reasoning_delta"), + ("content", self._content, "text_delta"), + ("refusal", self._refusal, "text_delta"), + ): + value = delta.get(key) + if value is None: + continue + if not isinstance(value, str): + self._fail("MODEL_RESPONSE_INVALID") + if value: + target.append(value) + payload = {"delta": value} + if kind == "text_delta": + payload["content_kind"] = "refusal" if key == "refusal" else "text" + events.append(self._event(kind, **payload)) + raw_calls = delta.get("tool_calls") + if raw_calls is not None: + if not isinstance(raw_calls, list): + self._fail("MODEL_TOOL_CALL_INVALID") + for raw_call in raw_calls: + events.append(self._call_delta(raw_call)) + unknown = { + key: value + for key, value in delta.items() + if key not in {"role", "content", "reasoning_content", "refusal", "tool_calls"} + } + if unknown: + self._unknown.append(unknown) + finish = choice.get("finish_reason") + if finish is not None: + if not isinstance(finish, str) or not finish.strip(): + self._fail("MODEL_RESPONSE_INVALID") + self._finish = finish + return events + except ModelAdapterError: + self._closed = True + raise + except (ValueError, TypeError, UnicodeError, RecursionError): + self._fail("MODEL_RESPONSE_INVALID") + + def _call_delta(self, raw): + if not isinstance(raw, dict) or type(raw.get("index")) is not int or raw["index"] < 0: + self._fail("MODEL_TOOL_CALL_INVALID") + index = raw["index"] + if index not in self._calls: + if len(self._calls) >= self._tool_limit: + self._fail("MODEL_TOOL_CALL_LIMIT") + self._calls[index] = {"local_id": uuid4(), "id": None, "name": [], "arguments": []} + state = self._calls[index] + if "type" in raw and raw["type"] != "function": + self._fail("MODEL_TOOL_CALL_INVALID") + if raw.get("id") is not None: + value = raw["id"] + if not isinstance(value, str) or not value.strip() or state["id"] is not None and state["id"] != value: + self._fail("MODEL_STREAM_IDENTITY_CONFLICT") + state["id"] = value + function = raw.get("function", {}) + if not isinstance(function, dict): + self._fail("MODEL_TOOL_CALL_INVALID") + for key in ("name", "arguments"): + if key in function: + if not isinstance(function[key], str): + self._fail("MODEL_TOOL_CALL_INVALID") + state[key].append(function[key]) + return self._event( + "tool_call_delta", + tool_call_delta=ToolCallDelta( + local_call_id=state["local_id"], + index=index, + provider_call_id=state["id"], + name_delta=function.get("name"), + arguments_delta=function.get("arguments"), + ), + ) + + def finish(self) -> list[ModelEvent]: + if self._closed: + return [] + if self._require_done: + self._fail("MODEL_RESPONSE_INCOMPLETE") + try: + return self._complete() + except ModelAdapterError: + self._closed = True + raise diff --git a/sdk/nexent/core/model_service/compatibility/__init__.py b/sdk/nexent/core/model_service/compatibility/__init__.py new file mode 100644 index 0000000000..6ec884076a --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/__init__.py @@ -0,0 +1 @@ +"""Compatibility with existing model consumers; internal imports use concrete modules.""" diff --git a/sdk/nexent/core/model_service/compatibility/invocation_options.py b/sdk/nexent/core/model_service/compatibility/invocation_options.py new file mode 100644 index 0000000000..e8a0365950 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/invocation_options.py @@ -0,0 +1,95 @@ +"""Legacy call options validated against the saved outlet's declarations.""" + +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules, overlay_saved_parameter_values +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, json_values_equal +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.models.capacity_budget import ( + CallerMaxTokensOverrideForbidden, + ContextBudgetSnapshot, + parse_context_budget_snapshot, + verify_context_budget_capacity, +) + + +@dataclass(frozen=True) +class LegacyInvocationOptions: + """Interface aliases only; no provider defaults or unregistered API fields.""" + + parameters: Mapping[str, object] + stop_sequences: object = None + response_format: object = None + context_budget_snapshot: object = None + capacity_snapshot: object = None + custom_overrides: Mapping[str, object] | None = None + + def resolve(self, profile: ResolvedModelProfile) -> ResolvedModelProfile: + submitted = dict(self.parameters) + for key, value in (("stop", self.stop_sequences), ("response_format", self.response_format)): + if value is None: + continue + if key in submitted and not json_values_equal(submitted[key], value): + raise ParameterValidationError("LEGACY_PARAMETER_CONFLICT", "/" + key) + submitted[key] = value + snapshot = profile.model_dump() + declared = {rule["key"] for rule in profile.parameter_rules} + # Old clients use these values to express omission, not new API fields. + if submitted.get("enable_thinking") is False and "enable_thinking" not in declared: + submitted.pop("enable_thinking") + if submitted.get("reasoning_effort") == "auto" and "reasoning_effort" not in declared: + submitted.pop("reasoning_effort") + if submitted.get("enable_thinking") is False: + for rule in profile.parameter_rules: + condition = rule.get("condition") + if (condition == {"key": "enable_thinking", "op": "eq", "value": True} + and rule["key"] not in submitted): + snapshot["parameter_values"].pop(rule["key"], None) + output_keys = {rule["key"] for rule in profile.parameter_rules if rule.get("api_binding")} & { + "max_tokens", "max_output_tokens", + } + if "max_tokens" in submitted and output_keys == {"max_output_tokens"}: + value = submitted.pop("max_tokens") + if "max_output_tokens" in submitted and not json_values_equal(submitted["max_output_tokens"], value): + raise ParameterValidationError("LEGACY_PARAMETER_CONFLICT", "/max_output_tokens") + submitted["max_output_tokens"] = value + if self.context_budget_snapshot is not None: + if not isinstance(self.context_budget_snapshot, (dict, ContextBudgetSnapshot)): + raise TypeError("context_budget_snapshot must be a ContextBudgetSnapshot or dict") + budget = parse_context_budget_snapshot(self.context_budget_snapshot) + verify_context_budget_capacity(budget_snapshot=budget, capacity_snapshot=self.capacity_snapshot) + verify_context_budget_capacity(budget_snapshot=budget, capacity_snapshot={ + "provider": profile.identity.provider_id, "model_name": profile.identity.request_model_id, + }) + if len(output_keys) != 1: + raise ParameterValidationError("LEGACY_OUTPUT_BINDING_REQUIRED") + key = next(iter(output_keys)) + if submitted.get(key) is not None and not json_values_equal(submitted[key], budget.requested_output_tokens): + raise CallerMaxTokensOverrideForbidden( + snapshot_value=budget.requested_output_tokens, caller_value=submitted[key], + ) + submitted[key] = budget.requested_output_tokens + if submitted: + snapshot["parameter_values"] = overlay_saved_parameter_values( + compile_parameter_rules(profile.parameter_rules), submitted, + scope="outlet", saved_values=snapshot["parameter_values"], + ) + if self.custom_overrides is not None: + from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters + + if not isinstance(self.custom_overrides, Mapping) or any( + not isinstance(key, str) or not key for key in self.custom_overrides + ): + raise ParameterValidationError("LEGACY_CUSTOM_PARAMETERS_INVALID") + extras = snapshot["extra_api_parameters"] + for key, value in self.custom_overrides.items(): + path = "/" + key.replace("~", "~0").replace("/", "~1") + extras = [entry for entry in extras if entry["path"] != path and not entry["path"].startswith(path + "/")] + if value is not None: + extras.append({"path": path, "value": value}) + apply_extra_api_parameters({}, extras, + parameter_paths=[rule["api_binding"]["path"] for rule in profile.parameter_rules if rule.get("api_binding")], + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + snapshot["extra_api_parameters"] = extras + return ResolvedModelProfile.model_validate(snapshot) diff --git a/sdk/nexent/core/model_service/compatibility/legacy_chat_model.py b/sdk/nexent/core/model_service/compatibility/legacy_chat_model.py new file mode 100644 index 0000000000..ff7edcd8c3 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/legacy_chat_model.py @@ -0,0 +1,409 @@ +"""Existing smolagents model interface over the one model service.""" + +from copy import deepcopy +from inspect import iscoroutinefunction +from uuid import uuid4 + +from smolagents.models import ( + ChatMessage, + ChatMessageStreamDelta, + ChatMessageToolCallFunction, + ChatMessageToolCallStreamDelta, + Model, + get_clean_message_list, + get_tool_json_schema, + tool_role_conversions, +) +from smolagents.monitoring import TokenUsage + +from nexent.core.concurrency.cancellation import RunCancellationScope +from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError +from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions +from nexent.core.model_service.contracts import ModelMessage, ModelRequest, ToolDefinition +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.registry import AuthenticationRegistry +from nexent.core.model_service.service import ModelService, ModelServiceError +from nexent.core.models.capacity_budget import parse_context_budget_snapshot +from nexent.core.models.message_utils import merge_system_messages +from nexent.core.models.prompt_cache import PromptCacheUsage +from nexent.core.models.retry import DEFAULT_MODEL_RETRY +from nexent.core.utils.observer import MessageObserver, ProcessType + + +_ERROR_CODES = { + "MODEL_EMPTY_RESPONSE": ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED, + "MODEL_AUTHENTICATION_ERROR": ModelErrorCode.AUTHENTICATION_ERROR, + "MODEL_NOT_FOUND": ModelErrorCode.NOT_FOUND, + "MODEL_RATE_LIMITED": ModelErrorCode.RATE_LIMIT_EXHAUSTED, + "MODEL_SERVICE_UNAVAILABLE": ModelErrorCode.SERVICE_UNAVAILABLE, + "MODEL_TIMEOUT": ModelErrorCode.TIMEOUT, + "MODEL_NETWORK_ERROR": ModelErrorCode.CONNECTION_ERROR, + "MODEL_CONTEXT_EXCEEDED": ModelErrorCode.CONTEXT_OVERFLOW, + "MODEL_REQUEST_INVALID": ModelErrorCode.INVALID_REQUEST, +} + + +class LegacyChatModel(Model): + """No provider branches, HTTP clients, tool execution or AgentMode fallback.""" + + supports_deferred_attempt_commit = True + supports_suppressed_attempt_stream = True + + def __init__( + self, profile, service, *, observer=None, cancellation_scope=None, flatten_messages_as_text=False, + context_budget_snapshot=None, capacity_snapshot=None, + ): + self.profile = ResolvedModelProfile.model_validate(profile.model_dump()) + super().__init__( + model_id=self.profile.identity.request_model_id, flatten_messages_as_text=flatten_messages_as_text + ) + self.service = service + self.observer = observer if observer is not None else MessageObserver() + self.cancellation_scope = cancellation_scope if cancellation_scope is not None else RunCancellationScope() + self.stop_event = self.cancellation_scope.stop_event + self.temperature = self.profile.parameter_values.get("temperature") + self.top_p = self.profile.parameter_values.get("top_p") + self.model_factory = self.profile.identity.provider_id + self.display_name = self.profile.identity.display_name + self.context_budget_snapshot = context_budget_snapshot + self.capacity_snapshot = deepcopy(capacity_snapshot) + self.last_input_token_count = None + self.last_output_token_count = None + self.last_finish_reason = None + self.last_response_diagnostics = None + self.last_attempt_number = None + self.last_attempt_id = None + self.last_reasoning_preview = None + self.last_cached_input_token_count = None + self.last_prompt_cache_usage = None + self.last_provider_cache_advice = None + self.last_retry_count = 0 + self.last_context_evidence = None + + @classmethod + def from_profile(cls, profile, api_key, *, observer=None, cancellation_scope=None): + """Construct the compatibility model with the shared transport and retry policy.""" + auth = AuthenticationRegistry() + service = ModelService( + lambda snapshot: auth.sender_for(snapshot, lambda _tenant, _reference: api_key), + retry_policy=DEFAULT_MODEL_RETRY, + ) + return cls(profile, service, observer=observer, cancellation_scope=cancellation_scope) + + @property + def context_budget_snapshot(self): + return self._context_budget_snapshot + + @context_budget_snapshot.setter + def context_budget_snapshot(self, value): + self._context_budget_snapshot = parse_context_budget_snapshot(deepcopy(value)) if value is not None else None + + async def check_connectivity(self): + """Probe endpoint/model with saved declarations through the same HTTP owner.""" + try: + request, profile = self._request( + [{"role": "user", "content": "Hello"}], LegacyInvocationOptions({}), None, + ) + await self.service.ainvoke( + request.model_copy(update={"stream": False}), profile, scope=self.cancellation_scope, + ) + return True + except Exception: + return False + + def _request(self, messages, options, tools, *, profile=None): + profile = options.resolve(self.profile if profile is None else profile) + normalized = [] + for message in merge_system_messages(deepcopy(messages)): + if isinstance(message, ChatMessage): + message = {"role": message.role, "content": message.content} + if not isinstance(message, dict) or not {"role", "content"} <= message.keys(): + raise ValueError("Legacy messages require role and content") + message = deepcopy(message) + if isinstance(message["content"], str): + message["content"] = [{"type": "text", "text": message["content"]}] + elif message["content"] is None: + message["content"] = [] + normalized.append(message) + cleaned = get_clean_message_list( + normalized, + role_conversions=tool_role_conversions, + convert_images_to_image_urls=True, + flatten_messages_as_text=self.flatten_messages_as_text, + ) + request = ModelRequest( + request_id=uuid4().hex, + outlet_id=profile.outlet_id, + config_revision=profile.config_revision, + stream=True, + messages=[ + ModelMessage( + semantic_role=getattr(message["role"], "value", message["role"]), + content_parts=( + [{"type": "text", "text": message["content"]}] + if isinstance(message["content"], str) + else message["content"] + ), + ) + for message in cleaned + ], + tools=[ + ToolDefinition( + name=definition["function"]["name"], + description=definition["function"]["description"], + input_schema=definition["function"]["parameters"], + ) + for definition in (get_tool_json_schema(tool) for tool in tools or []) + ], + ) + return request, profile + + @staticmethod + def _terminal(error): + return ModelInvocationTerminalError( + _ERROR_CODES.get(error.category, ModelErrorCode.UNKNOWN_ERROR), + error.attempt, + cause=ModelServiceError(error), + ) + + def _reset_response(self, profile): + self.last_input_token_count = self.last_output_token_count = self.last_finish_reason = None + self.last_cached_input_token_count = self.last_prompt_cache_usage = None + self.last_reasoning_preview = None + self._has_response_content = False + self.last_response_diagnostics = { + "finish_reason": None, "content_chunk_count": 0, "content_char_count": 0, + "reasoning_chunk_count": 0, "reasoning_char_count": 0, + "input_tokens": None, "output_tokens": None, + "requested_output_tokens": profile.parameter_values.get( + "max_output_tokens", profile.parameter_values.get("max_tokens") + ), + "reasoning_only_budget_exhausted": False, + } + + def _observe_event(self, event, profile): + diagnostics = self.last_response_diagnostics + kind = {"text_delta": "content", "reasoning_delta": "reasoning"}.get(event.type) + if kind is not None and event.content_kind != "refusal": + diagnostics[kind + "_chunk_count"] += 1 + diagnostics[kind + "_char_count"] += len(event.delta) + if kind == "content": + self._has_response_content |= bool(event.delta.strip()) + usage = event.usage if event.type == "usage" else event.message.usage if event.type == "message_completed" else None + if usage is not None: + self.last_input_token_count, self.last_output_token_count = usage.input_tokens, usage.output_tokens + self.last_cached_input_token_count = usage.cached_input_tokens + self.last_prompt_cache_usage = None + diagnostics.update(input_tokens=usage.input_tokens, output_tokens=usage.output_tokens) + if ( + usage.origin == "provider" and profile.capabilities.usage.input_includes_cached == "supported" + and usage.input_tokens is not None and usage.cached_input_tokens is not None + and usage.cached_input_tokens <= usage.input_tokens + ): + cached, total = usage.cached_input_tokens, usage.input_tokens + self.last_prompt_cache_usage = PromptCacheUsage( + cached_input_tokens=cached, uncached_input_tokens=total - cached, + provider_cache_hit=cached > 0, hit_ratio=round(cached / total, 4) if total else 0.0, + metrics_source=usage.semantics_id or "provider_reported", + ) + if event.type in {"message_completed", "error"}: + self.last_finish_reason = ( + event.message.finish_reason if event.type == "message_completed" else event.error.finish_reason + ) + diagnostics.update( + finish_reason=self.last_finish_reason, + reasoning_only_budget_exhausted=( + self.last_finish_reason == "length" and diagnostics["reasoning_char_count"] > 0 + and not self._has_response_content + ), + ) + + def _events( + self, messages, options, tools, *, suppress_stream=False, empty_response_policy="accept", context_rebuild=None, + ): + if context_rebuild is not None and ( + not callable(context_rebuild) or iscoroutinefunction(context_rebuild) + or iscoroutinefunction(getattr(context_rebuild, "__call__", None)) + ): + raise TypeError("Legacy context_rebuild must be a synchronous callback") + request, profile = self._request(messages, options, tools) + + def rebuild_request(_error): + rebuilt = context_rebuild() + rebuilt_messages = getattr(rebuilt, "messages", rebuilt) + if not isinstance(rebuilt_messages, list): + raise TypeError("context_rebuild must return FinalContext or a message list") + self.last_context_evidence = getattr(rebuilt, "evidence", None) + replacement, _ = self._request(rebuilt_messages, options, tools, profile=profile) + return replacement.model_copy(update={"request_id": request.request_id}) + + self.last_attempt_id = request.request_id + self.last_attempt_number = None + self.last_retry_count = 0 + self.last_context_evidence = None + self._reset_response(profile) + attempt_number = None + completed = False + if not suppress_stream: + self.observer.current_mode = ProcessType.MODEL_OUTPUT_THINKING + try: + with self.service.stream( + request, profile, scope=self.cancellation_scope, empty_response_policy=empty_response_policy, + request_rebuild=rebuild_request if context_rebuild is not None else None, + ) as events: + for event in events: + if attempt_number != event.attempt: + if attempt_number is not None and not suppress_stream: + self.observer.rollback_model_attempt(request.request_id, attempt_number) + attempt_number = event.attempt + self.last_attempt_id = request.request_id + self.last_attempt_number = event.attempt + self.last_retry_count = event.attempt - 1 + self._reset_response(profile) + if not suppress_stream: + self.observer.begin_model_attempt(request.request_id, event.attempt) + self._observe_event(event, profile) + if event.type == "error": + raise self._terminal(event.error) + if event.type == "reasoning_delta" and not suppress_stream: + self.observer.add_model_reasoning_content(event.delta) + elif event.type == "text_delta" and event.content_kind != "refusal" and not suppress_stream: + self.observer.add_model_new_token(event.delta) + elif event.type == "message_completed": + completed = True + if not suppress_stream: + self.observer.flush_remaining_tokens() + message = event.message + self.last_finish_reason = message.finish_reason + self.last_input_token_count = message.usage.input_tokens + self.last_output_token_count = message.usage.output_tokens + yield event + except ModelServiceError as error: + raise self._terminal(error.error) from None + finally: + if not completed and attempt_number is not None and not suppress_stream: + self.observer.rollback_model_attempt(request.request_id, attempt_number) + + def generate( + self, messages, stop_sequences=None, response_format=None, tools_to_call_from=None, + *, _defer_attempt_commit=False, _suppress_attempt_stream=False, _retry_empty_response=True, + context_budget_snapshot=None, context_rebuild=None, **kwargs, + ): + if any(type(value) is not bool for value in ( + _defer_attempt_commit, _suppress_attempt_stream, _retry_empty_response + )): + raise ValueError("Attempt controls must be booleans") + options = LegacyInvocationOptions( + kwargs, stop_sequences, response_format, + context_budget_snapshot if context_budget_snapshot is not None else self.context_budget_snapshot, + self.capacity_snapshot, + ) + events = self._events( + messages, options, tools_to_call_from, suppress_stream=_suppress_attempt_stream, + empty_response_policy="retry" if _retry_empty_response else "fail", + context_rebuild=context_rebuild, + ) + try: + for event in events: + if event.type != "message_completed": + continue + message = event.message + result = ChatMessage.from_dict( + { + "role": "assistant", + "content": "".join( + part["text"] for part in message.content_parts if part.get("type") == "text" + ), + "tool_calls": [ + { + "id": call.provider_call_id, + "type": "function", + "function": { + "name": call.name, + "arguments": call.raw_arguments, + }, + } + for call in message.tool_calls + ], + }, + raw=message, + ) + if message.usage.input_tokens is not None and message.usage.output_tokens is not None: + result.token_usage = TokenUsage(message.usage.input_tokens, message.usage.output_tokens) + result.model_attempt_id = self.last_attempt_id + result.model_attempt_number = event.attempt + result.model_attempt_commit_deferred = _defer_attempt_commit and not _suppress_attempt_stream + result.refusal = message.refusal + if not _defer_attempt_commit and not _suppress_attempt_stream: + self.observer.commit_model_attempt(self.last_attempt_id, event.attempt) + return result + raise RuntimeError("Model stream ended without completion") + finally: + events.close() + + def generate_stream( + self, messages, stop_sequences=None, response_format=None, tools_to_call_from=None, + *, _suppress_attempt_stream=False, context_budget_snapshot=None, context_rebuild=None, **kwargs, + ): + if type(_suppress_attempt_stream) is not bool: + raise ValueError("Attempt controls must be booleans") + options = LegacyInvocationOptions( + kwargs, stop_sequences, response_format, + context_budget_snapshot if context_budget_snapshot is not None else self.context_budget_snapshot, + self.capacity_snapshot, + ) + events = self._events( + messages, options, tools_to_call_from, suppress_stream=_suppress_attempt_stream, context_rebuild=context_rebuild, + ) + try: + for event in events: + delta = self._stream_delta(event) + if delta is not None: + yield delta + if event.type == "message_completed": + if not _suppress_attempt_stream: + self.observer.commit_model_attempt(self.last_attempt_id, event.attempt) + finally: + events.close() + + @staticmethod + def _stream_delta(event): + if event.type == "text_delta" and event.content_kind != "refusal": + return ChatMessageStreamDelta(content=event.delta) + elif event.type == "tool_call_delta": + delta = event.tool_call_delta + return ChatMessageStreamDelta( + tool_calls=[ + ChatMessageToolCallStreamDelta( + index=delta.index, + id=delta.provider_call_id, + type="function", + function=ChatMessageToolCallFunction( + name=delta.name_delta or "", arguments=delta.arguments_delta or "" + ), + ) + ] + ) + elif event.type == "usage": + usage = event.usage + if usage.input_tokens is not None and usage.output_tokens is not None: + return ChatMessageStreamDelta(token_usage=TokenUsage(usage.input_tokens, usage.output_tokens)) + + async def agenerate_stream( + self, messages, stop_sequences=None, response_format=None, tools_to_call_from=None, **kwargs, + ): + """Compatibility deltas over the asynchronous transport; the caller owns iteration.""" + options = LegacyInvocationOptions( + kwargs, stop_sequences, response_format, self.context_budget_snapshot, self.capacity_snapshot, + ) + request, profile = self._request(messages, options, tools_to_call_from) + try: + async with self.service.astream(request, profile, scope=self.cancellation_scope) as events: + async for event in events: + if event.type == "error": + raise self._terminal(event.error) + delta = self._stream_delta(event) + if delta is not None: + yield delta + except ModelServiceError as error: + raise self._terminal(error.error) from None diff --git a/sdk/nexent/core/model_service/compatibility/legacy_config.py b/sdk/nexent/core/model_service/compatibility/legacy_config.py new file mode 100644 index 0000000000..95223ce03e --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/legacy_config.py @@ -0,0 +1,107 @@ +"""Pure interpretation of pre-profile model parameters, shared with one-time migration.""" + +from typing import Any, Dict, Optional + +from nexent.core.model_service.compatibility.legacy_reasoning import legacy_reasoning_wire_parameters + + +COMMON_REASONING_LEVELS = ("low", "medium", "high") +COMMON_REASONING_DEFAULT = "auto" + +def legacy_thinking_enabled(extra_params: Optional[Dict[str, Any]]) -> bool: + """Return whether the model explicitly or implicitly enables reasoning.""" + if not isinstance(extra_params, dict): + return False + if isinstance(extra_params.get("enable_thinking"), bool): + return extra_params["enable_thinking"] + return ( + isinstance(extra_params.get("reasoning_effort"), str) + or isinstance(extra_params.get("reasoning_budget_tokens"), int) + ) + + +def legacy_extra_body(extra_params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Build extra_body for ModelConfig from model_record_t.extra_params. + + Merges __custom__ user-defined params into the top level so they flow + into OpenAI chat.completions.create request body. Returns None when + there is nothing to pass through so production behaviour is unchanged. + """ + if not extra_params or not isinstance(extra_params, dict): + return None + extra_body = dict(extra_params) + # These are dedicated ModelConfig fields, not provider request-body keys. + extra_body.pop("reasoning_effort", None) + extra_body.pop("reasoning_budget_tokens", None) + custom = extra_body.pop("__custom__", None) + if custom and isinstance(custom, dict): + extra_body.update(custom) + return extra_body if extra_body else None + + +def legacy_reasoning_effort( + extra_params: Optional[Dict[str, Any]], + capability: Optional[Dict[str, Any]], +) -> Optional[str]: + """Resolve the enabled model's effort from its supported profile.""" + if not isinstance(extra_params, dict) or not legacy_thinking_enabled(extra_params): + return None + if extra_params.get("reasoning_effort") == "auto": + return None + if isinstance(capability, dict) and capability.get("status") == "supported": + levels = capability.get("levels") or list(COMMON_REASONING_LEVELS) + default = capability.get("default") + if default not in levels: + default = COMMON_REASONING_DEFAULT + else: + # Unknown/custom model IDs still use the common generic profile. The + # provider remains the source of truth when a concrete value is sent. + levels = list(COMMON_REASONING_LEVELS) + default = COMMON_REASONING_DEFAULT + saved = extra_params.get("reasoning_effort") + if saved in levels: + return saved + # Auto is the universal fallback: omit the provider-specific effort so the + # model can choose its own reasoning depth. + return None if saved is None or default == COMMON_REASONING_DEFAULT else default + + +def legacy_reasoning_budget( + extra_params: Optional[Dict[str, Any]], + capability: Optional[Dict[str, Any]], +) -> Optional[int]: + """Resolve a persisted numeric reasoning budget within the source range.""" + if not isinstance(extra_params, dict) or not legacy_thinking_enabled(extra_params): + return None + value = extra_params.get("reasoning_budget_tokens") + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + controls = capability.get("controls") if isinstance(capability, dict) else None + budget_control = next( + (control for control in controls or [] + if isinstance(control, dict) and control.get("type") == "budget_tokens"), + None, + ) + if isinstance(budget_control, dict): + minimum = budget_control.get("min") + maximum = budget_control.get("max") + if isinstance(minimum, int) and isinstance(maximum, int): + return min(maximum, max(minimum, value)) + return value + + + +def legacy_agent_request_parameters(record, *, model_id, capability): + """Resolve the old Agent's sampling/reasoning body without reading credentials.""" + if record.get("model_type") not in {"llm", "chat"}: + raise ValueError("LEGACY_MODEL_TYPE_UNSUPPORTED") + extra = record.get("extra_params") + if extra is not None and not isinstance(extra, dict): + raise ValueError("LEGACY_PARAMETERS_INVALID") + body = legacy_reasoning_wire_parameters( + model_id=model_id, base_url=record.get("base_url"), model_factory=record.get("model_factory"), + capability=capability, effort=legacy_reasoning_effort(extra, capability), + budget=legacy_reasoning_budget(extra, capability), extra_body=legacy_extra_body(extra), + ) + # The old Agent passes these kwargs even when SQL values are NULL; custom body wins. + return {"temperature": record.get("temperature"), "top_p": record.get("top_p"), **body} diff --git a/sdk/nexent/core/model_service/compatibility/legacy_reasoning.py b/sdk/nexent/core/model_service/compatibility/legacy_reasoning.py new file mode 100644 index 0000000000..5db2e7d8be --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/legacy_reasoning.py @@ -0,0 +1,269 @@ +"""Pure legacy reasoning conversions reused by SDK compatibility and outlet migration.""" + +import logging +from copy import deepcopy +from typing import Any, Dict, Optional +from urllib.parse import urlsplit + + +logger = logging.getLogger("openai_llm") + + +class ReasoningConfigurationError(RuntimeError): + """Raised when a provider rejects the configured reasoning parameters.""" + + is_reasoning_configuration_error = True + + +class LegacyReasoningPolicy: + """Legacy field translation only; no inference, lifecycle or transport ownership.""" + + def _reasoning_wire_profile(self) -> Optional[Dict[str, Optional[str]]]: + """Return only an explicitly known provider wire mapping. + + models.dev declares that a model has reasoning controls, but it does + not define a universal request field for every OpenAI-compatible + provider. Confirmed providers use their own adapter. For an unknown + provider, an enum effort falls back to OpenAI's public + ``reasoning_effort`` field. Numeric budgets still require an explicit + provider adapter; an explicitly supplied thinking toggle retains the + legacy top-level passthrough for generic compatible gateways. + """ + capability = self.reasoning_capability or {} + provider_id = str( + capability.get("provider_id") or self.model_factory or "" + ).lower() + api_url = str( + capability.get("matched_api") or self.api_base_url or "" + ).lower() + try: + api_hostname = ( + urlsplit(api_url if "://" in api_url else f"//{api_url}").hostname + or "" + ).rstrip(".") + except ValueError: + api_hostname = "" + if api_hostname == "aliyuncs.com" or api_hostname.endswith(".aliyuncs.com"): + provider_id = "dashscope" + elif api_hostname == "api.deepseek.com": + provider_id = "deepseek" + + if capability.get("source") == "models_dev": + if provider_id in {"alibaba", "alibaba-cn", "dashscope"}: + return { + "effort": "reasoning_effort", + "budget": "thinking_budget", + "toggle": "enable_thinking", + } + if provider_id == "deepseek": + return { + "effort": "reasoning_effort", + "budget": None, + "toggle": "thinking_object", + } + if provider_id in {"openai", "google"}: + return { + "effort": "reasoning_effort", + "budget": None, + "toggle": None, + } + if capability.get("wire_format") == "reasoning_effort": + return { + "effort": "reasoning_effort", + "budget": None, + "toggle": None, + } + return { + "effort": "reasoning_effort", + "budget": None, + "toggle": None, + } + + effort = capability.get("wire_format") or "reasoning_effort" + if effort not in {"reasoning_effort", "thinking_toggle", "thinking_budget"}: + # OpenAI-compatible endpoints have one public effort field. Use + # it as the conservative fallback for an unconfirmed provider; + # provider-specific budget/toggle fields are never inferred. + effort = "reasoning_effort" + budget = capability.get("budget_wire_format") + if budget is None and effort == "thinking_budget": + # Existing operator/catalog profiles explicitly using the + # thinking-budget wire format use the nested thinking object. + budget = "thinking_object" + toggle = capability.get("toggle_wire_format") + if toggle is None and provider_id == "deepseek": + toggle = "thinking_object" + if toggle is None and provider_id in {"alibaba", "alibaba-cn", "dashscope"}: + toggle = "enable_thinking" + return {"effort": effort, "budget": budget, "toggle": toggle} + + def _translate_thinking_flag( + self, extra_body: Dict[str, Any] + ) -> Dict[str, Any]: + """Translate the enable_thinking flag to the provider's wire format. + + Qwen-family self-hosted deployments read the flag from + ``chat_template_kwargs.enable_thinking``. DashScope's OpenAI-compatible + endpoint accepts the top-level ``enable_thinking`` extra field. Known + providers use their adapter; unknown compatible gateways retain an + explicitly supplied top-level flag for backward compatibility. + """ + if "enable_thinking" not in extra_body: + return extra_body + translated = dict(extra_body) + thinking = translated.pop("enable_thinking") + wire_profile = self._reasoning_wire_profile() + toggle_format = wire_profile.get("toggle") if wire_profile else None + if toggle_format == "thinking_object": + translated["thinking"] = { + "type": "enabled" if thinking else "disabled" + } + elif toggle_format == "enable_thinking": + translated["enable_thinking"] = thinking + elif toggle_format == "chat_template" or ( + toggle_format is None + and wire_profile is not None + and (self.reasoning_capability or {}).get("source") != "models_dev" + and "qwen" in (self.model_id or "").lower() + ): + chat_kwargs = translated.get("chat_template_kwargs") + if isinstance(chat_kwargs, dict): + translated["chat_template_kwargs"] = { + **chat_kwargs, "enable_thinking": thinking, + } + else: + translated["chat_template_kwargs"] = {"enable_thinking": thinking} + elif toggle_format is None: + capability = self.reasoning_capability or {} + provider_id = str( + capability.get("provider_id") or self.model_factory or "" + ).lower() + if capability.get("status") != "unsupported" and not ( + capability.get("source") == "models_dev" + and provider_id in {"openai", "google"} + ): + # Preserve the pre-merge behavior for generic OpenAI-compatible + # gateways. This only forwards a toggle explicitly supplied by + # the caller; it does not enable the flag for every request. + translated["enable_thinking"] = thinking + return translated + + def _apply_reasoning_control(self, completion_kwargs: Dict[str, Any]) -> None: + """Apply the catalog's canonical effort using the provider wire format. + + Most OpenAI-compatible endpoints accept a top-level + ``reasoning_effort``. A few providers expose the same concept as a + ``thinking`` object, so the catalog declares that translation instead + of making the runtime guess from a model name. + """ + if ( + (self.reasoning_effort is None or self.reasoning_effort == "auto") + and self.reasoning_budget_tokens is None + ): + return + + capability = self.reasoning_capability or {} + if capability.get("status") == "unsupported": + return + wire_profile = self._reasoning_wire_profile() + if wire_profile is None: + logger.warning( + "event=reasoning_wire_adapter_missing model_id=%s provider=%s", + self.model_id, + capability.get("provider_id") or self.model_factory or "unknown", + ) + return + wire_format = wire_profile.get("effort") + + controls = capability.get("controls") + budget_control_declared = any( + isinstance(control, dict) and control.get("type") == "budget_tokens" + for control in controls or [] + ) + budget_wire_format = wire_profile.get("budget") + + # Numeric budget and enum effort are alternative controls. If both are + # present in a historical/configured value, numeric budget wins. + has_budget = ( + budget_control_declared + and isinstance(self.reasoning_budget_tokens, int) + and not isinstance(self.reasoning_budget_tokens, bool) + and self.reasoning_budget_tokens > 0 + ) + if has_budget: + completion_kwargs.pop("reasoning_effort", None) + if budget_wire_format == "thinking_budget": + extra_body = dict(completion_kwargs.get("extra_body") or {}) + extra_body["thinking_budget"] = self.reasoning_budget_tokens + completion_kwargs["extra_body"] = self._translate_thinking_flag(extra_body) + return + if budget_wire_format is None: + logger.warning( + "event=reasoning_budget_adapter_missing model_id=%s provider=%s", + self.model_id, + capability.get("provider_id") or self.model_factory or "unknown", + ) + return + elif budget_control_declared: + # Auto means no provider reasoning parameter. + return + + if wire_format == "reasoning_effort" and not has_budget: + if self.reasoning_effort is not None and self.reasoning_effort != "auto": + completion_kwargs["reasoning_effort"] = self.reasoning_effort + return + + if wire_format is None: + logger.warning( + "event=reasoning_effort_adapter_missing model_id=%s provider=%s", + self.model_id, + capability.get("provider_id") or self.model_factory or "unknown", + ) + return + + if budget_control_declared and self.reasoning_budget_tokens is None: + return + + extra_body = dict(completion_kwargs.get("extra_body") or {}) + thinking = extra_body.get("thinking") + thinking = dict(thinking) if isinstance(thinking, dict) else {} + + if self.reasoning_budget_tokens is not None: + thinking["type"] = "enabled" + thinking["budget_tokens"] = self.reasoning_budget_tokens + elif self.reasoning_effort == "none": + thinking["type"] = "disabled" + thinking.pop("budget_tokens", None) + else: + thinking["type"] = "enabled" + if wire_format == "thinking_budget": + budgets = capability.get("effort_budgets") or {} + budget = budgets.get(self.reasoning_effort) + if budget is None: + raise ReasoningConfigurationError( + f"Missing thinking budget for reasoning effort: {self.reasoning_effort}" + ) + thinking["budget_tokens"] = budget + + extra_body["thinking"] = thinking + completion_kwargs["extra_body"] = self._translate_thinking_flag(extra_body) + + + +def legacy_reasoning_wire_parameters(*, model_id, base_url, model_factory, capability=None, + effort=None, budget=None, extra_body=None): + """Export actual legacy body fields without allocating a client or sending a request.""" + policy = LegacyReasoningPolicy() + policy.model_id = model_id + policy.api_base_url = base_url + policy.model_factory = (model_factory or "").lower() + policy.reasoning_capability = deepcopy(capability) + policy.reasoning_effort = effort + policy.reasoning_budget_tokens = budget + parameters = {} + if extra_body: + parameters["extra_body"] = policy._translate_thinking_flag(deepcopy(extra_body)) + policy._apply_reasoning_control(parameters) + body = parameters.pop("extra_body", {}) + # The OpenAI SDK merges extra_body over ordinary completion parameters. + return deepcopy({**parameters, **body}) diff --git a/sdk/nexent/core/model_service/compatibility/long_text.py b/sdk/nexent/core/model_service/compatibility/long_text.py new file mode 100644 index 0000000000..b4697b3658 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/long_text.py @@ -0,0 +1,202 @@ +"""Existing long-text preparation shared independently of the model sender.""" + +import logging + +import tiktoken +from smolagents.models import ChatMessage + + +logger = logging.getLogger("openai_long_context_model") + + +class LongTextPreparation: + """Preserve the existing file truncation and message assembly policy.""" + + def initialize_long_text(self, max_context_tokens, truncation_strategy): + if type(max_context_tokens) is not int or max_context_tokens <= 0: + raise ValueError("Long-text context limit must be a positive integer") + if truncation_strategy not in ("start", "middle", "end"): + raise ValueError("truncation_strategy must be 'start', 'middle' or 'end'") + self.max_context_tokens = max_context_tokens + self.truncation_strategy = truncation_strategy + self._tokenizer = None + + def _get_tokenizer(self): + """Get tokenizer, used to calculate token number""" + if self._tokenizer is None: + try: + self._tokenizer = tiktoken.get_encoding("cl100k_base") + except Exception as exc: + # If tiktoken is unavailable or cannot load its encoding cache, + # use simple character count estimation. + logger.warning(f"Failed to load tiktoken encoding, using estimation: {exc}") + self._tokenizer = None + return self._tokenizer + + def count_tokens(self, text: str) -> int: + """ + Calculate the token number of the text + + Args: + text: The text to calculate + + Returns: + int: token number + """ + tokenizer = self._get_tokenizer() + if tokenizer: + token_count = len(tokenizer.encode(text)) + logger.debug(f"Token count using tiktoken: {token_count} tokens for text length {len(text)}") + return token_count + else: + # Simple character count estimation (approximately 4 characters = 1 token) + estimated_tokens = len(text) // 4 + logger.debug(f"Token count using estimation: {estimated_tokens} tokens for text length {len(text)} (4 chars ≈ 1 token)") + return estimated_tokens + + def truncate_text(self, text: str, max_tokens: int) -> str: + """ + Truncate the text to the specified token number + + Args: + text: The text to truncate + max_tokens: Maximum token number + + Returns: + str: Truncated text + """ + original_tokens = self.count_tokens(text) + logger.info(f"Starting text truncation: original_tokens={original_tokens}, max_tokens={max_tokens}, strategy={self.truncation_strategy}, text_length={len(text)}") + + if original_tokens <= max_tokens: + return text + + tokenizer = self._get_tokenizer() + + if tokenizer: + logger.debug("Using tiktoken tokenizer for precise truncation") + # Use tiktoken for precise truncation + tokens = tokenizer.encode(text) + if len(tokens) <= max_tokens: + logger.debug(f"Token count within limit after encoding: {len(tokens)} <= {max_tokens}") + return text + + if self.truncation_strategy == "start": + # Only keep the beginning part + logger.info(f"Truncating with 'start' strategy: keeping first {max_tokens} tokens") + truncated_tokens = tokens[:max_tokens] + truncated_text = tokenizer.decode(truncated_tokens) + elif self.truncation_strategy == "middle": + # Keep the beginning and end, + half_tokens = max_tokens // 2 + start_tokens = tokens[:half_tokens] + end_tokens = tokens[-(max_tokens - half_tokens):] + truncated_tokens = start_tokens + end_tokens + truncated_text = tokenizer.decode(truncated_tokens) + else: + # Only keep the end part + logger.info(f"Truncating with 'end' strategy: keeping last {max_tokens} tokens") + truncated_tokens = tokens[-max_tokens:] + truncated_text = tokenizer.decode(truncated_tokens) + else: + logger.warning("tiktoken not available, using character count estimation for truncation") + # Use character count for estimation truncation + estimated_chars = max_tokens * 4 + if len(text) <= estimated_chars: + logger.debug(f"Text length within estimated limit: {len(text)} <= {estimated_chars} chars") + return text + + if self.truncation_strategy == "start": + # Only keep the beginning part + truncated_text = text[:estimated_chars] + elif self.truncation_strategy == "middle": + # Keep the beginning and end + half_chars = estimated_chars // 2 + start_text = text[:half_chars] + end_text = text[-(estimated_chars - half_chars):] + truncated_text = start_text + "\n\n[Content truncated...]\n\n" + end_text + else: # end + # Only keep the end part + truncated_text = text[-estimated_chars:] + + # Calculate retention percentage (integer only) + retention_percentage = int((len(truncated_text) / len(text)) * 100) + logger.info(f"Truncation completed: {len(text)} -> {len(truncated_text)} characters, retained {retention_percentage}% of original content") + return truncated_text + + def prepare_long_text_message(self, text_content: str, system_prompt: str, user_prompt: str): + """ + Prepare the message format containing long text, automatically handle truncation + + Args: + text_content: Text content + system_prompt: System prompt + user_prompt: User prompt + + Returns: + tuple[List[Dict[str, Any]], str]: Prepared message list and truncation percentage string + """ + logger.info("Preparing long text message with automatic truncation") + + # Calculate the token number of the system prompt and user prompt + system_tokens = self.count_tokens(system_prompt) + user_prompt_tokens = self.count_tokens(user_prompt) + content_tokens = self.count_tokens(text_content) + + logger.info(f"Token breakdown: system={system_tokens}, user_prompt={user_prompt_tokens}, content={content_tokens}, max_context={self.max_context_tokens}") + logger.debug(f"Text lengths: system={len(system_prompt)}, user_prompt={len(user_prompt)}, content={len(text_content)}") + + # Reserve tokens for text content + available_tokens = self.max_context_tokens - system_tokens - user_prompt_tokens - 100 # Reserve 100 tokens as buffer + + # Check if there are sufficient tokens available + if available_tokens <= 0: + error_msg = f"Insufficient tokens available. Required: {system_tokens + user_prompt_tokens + 100}, Available: {self.max_context_tokens}, Shortage: {abs(available_tokens)}" + logger.error(error_msg) + raise ValueError(error_msg) + + # Truncate the text content + truncated_text = self.truncate_text(text_content, available_tokens) + final_content_tokens = self.count_tokens(truncated_text) + logger.info(f"Content truncation result: {content_tokens} -> {final_content_tokens} tokens") + + # Calculate truncation percentage + truncation_percentage = int((final_content_tokens / content_tokens) * 100) if content_tokens > 0 else 100 + + # Build messages + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"{user_prompt}\n\n{truncated_text}"} + ] + + total_message_tokens = system_tokens + user_prompt_tokens + final_content_tokens + logger.info(f"Message preparation completed: total_tokens={total_message_tokens}, messages_count={len(messages)}, truncation_percentage={truncation_percentage}%") + + return messages, str(truncation_percentage) + + def analyze_long_text(self, text_content: str, system_prompt: str, user_prompt: str) -> tuple[ChatMessage, str]: + """ + Analyze the long text content + + Args: + text_content: Text content + system_prompt: System prompt + user_prompt: User prompt + + Returns: + tuple[ChatMessage, str]: Model returned message and truncation percentage string + """ + logger.info("Starting long text analysis") + logger.debug(f"Input parameters: content_length={len(text_content)}, system_prompt_length={len(system_prompt)}, user_prompt_length={len(user_prompt)}") + + try: + messages, truncation_percentage = self.prepare_long_text_message(text_content, system_prompt, user_prompt) + logger.info("Messages prepared successfully, calling model for analysis") + + result = self(messages=messages) + logger.info("Long text analysis completed successfully") + return result, truncation_percentage + + except Exception as e: + logger.error(f"Error during long text analysis: {str(e)}") + raise diff --git a/sdk/nexent/core/model_service/concurrency.py b/sdk/nexent/core/model_service/concurrency.py new file mode 100644 index 0000000000..fbe0872110 --- /dev/null +++ b/sdk/nexent/core/model_service/concurrency.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import threading +import time +from dataclasses import dataclass + + +class ModelConcurrencyExceeded(TimeoutError): + """Raised when a model concurrency permit is unavailable before deadline.""" + + +@dataclass +class _LimiterEntry: + limit: int + semaphore: threading.BoundedSemaphore + references: int = 0 + + +class ModelConcurrencyPermit: + def __init__(self, limiter: "ModelConcurrencyLimiter", key: tuple[str, str, str], entry: _LimiterEntry): + self._limiter = limiter + self._key = key + self._entry = entry + self._released = False + self._lock = threading.Lock() + + def release(self) -> None: + with self._lock: + if self._released: + return + self._released = True + self._entry.semaphore.release() + self._limiter._release_reference(self._key, self._entry) + + +class ModelConcurrencyLimiter: + """Process-local concurrency limits shared by tenant/provider/model key.""" + + def __init__(self): + self._lock = threading.Lock() + self._entries: dict[tuple[str, str, str], _LimiterEntry] = {} + + def acquire( + self, + key: tuple[str, str, str], + limit: int, + timeout_seconds: float, + stop_event: threading.Event, + ) -> ModelConcurrencyPermit: + if limit <= 0: + raise ValueError("model concurrency limit must be greater than zero") + if timeout_seconds <= 0: + raise ValueError("model concurrency wait timeout must be greater than zero") + entry = self._retain_entry(key, limit) + + deadline = time.monotonic() + timeout_seconds + try: + while not stop_event.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ModelConcurrencyExceeded( + f"model concurrency permit timed out after {timeout_seconds:.3f} seconds" + ) + if entry.semaphore.acquire(timeout=min(0.05, remaining)): + return ModelConcurrencyPermit(self, key, entry) + raise RuntimeError("model concurrency wait cancelled") + except BaseException: + self._release_reference(key, entry) + raise + + def _retain_entry(self, key, limit): + with self._lock: + entry = self._entries.get(key) + if entry is None: + entry = _LimiterEntry(limit, threading.BoundedSemaphore(limit)) + self._entries[key] = entry + elif entry.limit != limit: + raise ValueError("conflicting concurrency limits for the same model key") + entry.references += 1 + return entry + + async def acquire_async( + self, + key: tuple[str, str, str], + limit: int, + timeout_seconds: float, + stop_event: threading.Event, + ) -> ModelConcurrencyPermit: + """Share sync admission state while never blocking the caller's event loop.""" + if limit <= 0: + raise ValueError("model concurrency limit must be greater than zero") + if timeout_seconds <= 0: + raise ValueError("model concurrency wait timeout must be greater than zero") + entry = self._retain_entry(key, limit) + deadline = time.monotonic() + timeout_seconds + try: + while not stop_event.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ModelConcurrencyExceeded( + f"model concurrency permit timed out after {timeout_seconds:.3f} seconds" + ) + if entry.semaphore.acquire(blocking=False): + return ModelConcurrencyPermit(self, key, entry) + await asyncio.sleep(min(0.05, remaining)) + raise RuntimeError("model concurrency wait cancelled") + except BaseException: + self._release_reference(key, entry) + raise + + def _release_reference(self, key: tuple[str, str, str], entry: _LimiterEntry) -> None: + with self._lock: + entry.references -= 1 + if entry.references == 0 and self._entries.get(key) is entry: + self._entries.pop(key, None) + + +model_concurrency_limiter = ModelConcurrencyLimiter() diff --git a/sdk/nexent/core/model_service/contracts.py b/sdk/nexent/core/model_service/contracts.py new file mode 100644 index 0000000000..e42618ab2f --- /dev/null +++ b/sdk/nexent/core/model_service/contracts.py @@ -0,0 +1,158 @@ +"""Provider-independent model messages, requests and complete responses.""" + +from typing import Annotated, Literal, Self +from uuid import UUID, uuid4 + +from pydantic import Field, JsonValue, model_validator + +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +NonnegativeInt = Annotated[int, Field(ge=0)] + + +class ToolCall(ProfileContract): + """Complete protocol call; parsing never constitutes execution permission.""" + + local_call_id: UUID = Field(default_factory=uuid4) + provider_call_id: NonBlankString + index: NonnegativeInt + name: NonBlankString + raw_arguments: str = Field(repr=False) + parsed_arguments: dict[str, JsonValue] | None = Field(default=None, repr=False) + argument_error: Literal["INVALID_TOOL_ARGUMENTS"] | None = None + + +class ToolDefinition(ProfileContract): + name: NonBlankString + description: str + input_schema: dict[str, JsonValue] = Field(repr=False) + + +class ModelMessage(ProfileContract): + message_id: UUID = Field(default_factory=uuid4) + semantic_role: NonBlankString + content_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + reasoning_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + tool_calls: list[ToolCall] = Field(default_factory=list, repr=False) + tool_call_id: NonBlankString | None = None + source: NonBlankString | None = None + + +class ModelRequest(ProfileContract): + request_id: NonBlankString + outlet_id: Annotated[int, Field(gt=0)] | None + config_revision: Annotated[int, Field(gt=0)] + messages: list[ModelMessage] = Field(min_length=1, repr=False) + tools: list[ToolDefinition] = Field(default_factory=list, repr=False) + stream: bool = False + purpose: NonBlankString = "agent" + run_id: NonBlankString | None = None + + @model_validator(mode="after") + def validate_identities(self) -> Self: + if len({message.message_id for message in self.messages}) != len(self.messages): + raise ValueError("Model message identities must be unique") + if len({tool.name for tool in self.tools}) != len(self.tools): + raise ValueError("Model tool names must be unique") + return self + + +class TokenUsage(ProfileContract): + input_tokens: NonnegativeInt | None = None + output_tokens: NonnegativeInt | None = None + total_tokens: NonnegativeInt | None = None + cached_input_tokens: NonnegativeInt | None = None + reasoning_tokens: NonnegativeInt | None = None + origin: Literal["provider", "unknown"] = "unknown" + semantics_id: NonBlankString | None = None + + +class AssistantMessage(ProfileContract): + message_id: UUID = Field(default_factory=uuid4) + content_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + reasoning_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + refusal: str | None = Field(default=None, repr=False) + tool_calls: list[ToolCall] = Field(default_factory=list, repr=False) + finish_reason: NonBlankString + usage: TokenUsage = Field(default_factory=TokenUsage) + provider_metadata: dict[str, JsonValue] = Field(default_factory=dict, repr=False) + + @model_validator(mode="after") + def validate_call_identities(self) -> Self: + for values in ([call.provider_call_id for call in self.tool_calls], [call.index for call in self.tool_calls]): + if len(set(values)) != len(values): + raise ValueError("Complete tool call identities must be unique") + return self + + +class ToolCallDelta(ProfileContract): + """Display/accumulation only: never contains parsed execution arguments.""" + + local_call_id: UUID + index: NonnegativeInt + provider_call_id: NonBlankString | None = None + name_delta: str | None = Field(default=None, repr=False) + arguments_delta: str | None = Field(default=None, repr=False) + + +class ModelError(ProfileContract): + category: NonBlankString + scope: Literal["transport", "provider", "protocol"] + retryable: bool + will_retry: bool = False + attempt: Annotated[int, Field(gt=0)] = 1 + max_attempts: Annotated[int, Field(gt=0)] = 1 + provider_code: str | None = None + safe_message: NonBlankString + request_id: NonBlankString + finish_reason: NonBlankString | None = None + + @model_validator(mode="after") + def validate_attempt(self) -> Self: + if self.attempt > self.max_attempts: + raise ValueError("Model attempt exceeds its configured maximum") + if self.will_retry and (not self.retryable or self.attempt >= self.max_attempts): + raise ValueError("Model retry requires remaining attempts and a retryable error") + return self + + +class ModelEvent(ProfileContract): + request_id: NonBlankString + message_id: UUID + sequence: Annotated[int, Field(gt=0)] + attempt: Annotated[int, Field(gt=0)] = 1 + type: Literal["text_delta", "reasoning_delta", "tool_call_delta", "message_completed", "usage", "error"] + delta: str | None = Field(default=None, repr=False) + content_kind: Literal["text", "refusal"] | None = None + tool_call_delta: ToolCallDelta | None = Field(default=None, repr=False) + message: AssistantMessage | None = Field(default=None, repr=False) + usage: TokenUsage | None = None + finish_reason: NonBlankString | None = None + error: ModelError | None = Field(default=None, repr=False) + + @model_validator(mode="after") + def validate_payload(self) -> Self: + required = { + "text_delta": "delta", + "reasoning_delta": "delta", + "tool_call_delta": "tool_call_delta", + "message_completed": "message", + "usage": "usage", + "error": "error", + }[self.type] + payloads = { + key for key in ("delta", "tool_call_delta", "message", "usage", "error") if getattr(self, key) is not None + } + if payloads != {required}: + raise ValueError("Model event requires exactly its declared payload") + if self.content_kind is not None and self.type != "text_delta": + raise ValueError("Content kind applies only to text events") + if self.type == "message_completed": + if self.message.message_id != self.message_id or self.finish_reason != self.message.finish_reason: + raise ValueError("Completion identity and finish reason must agree") + elif self.finish_reason is not None: + raise ValueError("Only completed messages carry finish reasons") + if self.error is not None and self.error.request_id != self.request_id: + raise ValueError("Model error request identity must agree") + return self diff --git a/sdk/nexent/core/model_service/errors.py b/sdk/nexent/core/model_service/errors.py new file mode 100644 index 0000000000..9dadf46dc3 --- /dev/null +++ b/sdk/nexent/core/model_service/errors.py @@ -0,0 +1,114 @@ +"""Fixed error classification; provider text remains private to the adapter.""" + +import logging + +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError, parse_json +from nexent.core.model_service.contracts import ModelError +from nexent.core.model_service.http_contracts import ModelTransportError + + +logger = logging.getLogger(__name__) +_PROTOCOL_DIAGNOSTICS = frozenset({ + "MODEL_RESPONSE_INVALID", "MODEL_RESPONSE_INCOMPLETE", "MODEL_TOOL_CALL_INVALID", + "MODEL_STREAM_IDENTITY_CONFLICT", "MODEL_USAGE_CONFLICT", "MODEL_USAGE_INVALID", + "MODEL_STREAM_AFTER_FINISH", "MODEL_CONTENT_UNSUPPORTED", "MODEL_MESSAGE_INVALID", + "MODEL_ROLE_UNSUPPORTED", "MODEL_RESPONSE_TOO_LARGE", "MODEL_TOOL_CALL_LIMIT", + "MODEL_REQUEST_CONFIGURATION_INVALID", "MODEL_REASONING_FORMAT_UNSUPPORTED", + "MODEL_SNAPSHOT_MISMATCH", "MODEL_PROTOCOL_UNSUPPORTED", "NATIVE_TOOL_PROTOCOL_UNAVAILABLE", + "HTTP_RESPONSE_TOO_LARGE", "HTTP_STREAM_FORMAT_INVALID", "SSE_INVALID_TEXT", + "SSE_EVENT_TOO_LARGE", "SSE_INCOMPLETE_EVENT", "SSE_DECODER_CLOSED", +}) + + +_CATEGORIES = { + "MODEL_AUTHENTICATION_ERROR": (False, "Model service authentication failed."), + "MODEL_NOT_FOUND": (False, "The requested model service was not found."), + "MODEL_RATE_LIMITED": (True, "The model service is rate limited."), + "MODEL_SERVICE_UNAVAILABLE": (True, "The model service is temporarily unavailable."), + "MODEL_REQUEST_INVALID": (False, "The model service rejected the request."), + "MODEL_CONTEXT_EXCEEDED": (False, "The model service context limit was exceeded."), + "MODEL_QUOTA_EXHAUSTED": (False, "The model service quota is exhausted."), + "MODEL_TIMEOUT": (True, "The model request timed out."), + "MODEL_NETWORK_ERROR": (True, "The model service connection failed."), + "MODEL_TLS_ERROR": (False, "The model service TLS connection failed."), + "MODEL_HTTP_ERROR": (False, "The model service returned an unsupported HTTP status."), + "MODEL_PROVIDER_ERROR": (False, "The model service returned a provider error."), + "MODEL_CREDENTIAL_ERROR": (False, "Model service credentials could not be resolved."), + "MODEL_PROTOCOL_ERROR": (False, "The model response could not be processed."), + "MODEL_CONCURRENCY_TIMEOUT": (False, "Model concurrency admission timed out."), + "MODEL_RUN_DEADLINE": (False, "The model run time budget was exhausted."), +} +_PROVIDER_CODES = { + "context_length_exceeded": "MODEL_CONTEXT_EXCEEDED", + "invalid_api_key": "MODEL_AUTHENTICATION_ERROR", + "insufficient_quota": "MODEL_QUOTA_EXHAUSTED", + "model_not_found": "MODEL_NOT_FOUND", + "rate_limit_exceeded": "MODEL_RATE_LIMITED", +} + + +def normalize_model_error( + error, *, request_id: str, attempt: int = 1, max_attempts: int = 1, will_retry: bool = False +) -> ModelError: + """Classify trusted error types and known codes, never arbitrary error messages.""" + category, scope, provider_code = "MODEL_PROTOCOL_ERROR", "protocol", None + if isinstance(error, ModelTransportError): + scope = "transport" + category = { + "HTTP_TIMEOUT": "MODEL_TIMEOUT", + "HTTP_NETWORK_ERROR": "MODEL_NETWORK_ERROR", + "HTTP_TLS_ERROR": "MODEL_TLS_ERROR", + "CREDENTIAL_RESOLUTION_FAILED": "MODEL_CREDENTIAL_ERROR", + "CREDENTIAL_HEADER_CONFLICT": "MODEL_CREDENTIAL_ERROR", + "HTTP_CONCURRENCY_TIMEOUT": "MODEL_CONCURRENCY_TIMEOUT", + "HTTP_RUN_DEADLINE": "MODEL_RUN_DEADLINE", + }.get(error.category, "MODEL_PROTOCOL_ERROR") + response = ( + getattr(error, "response", None) if isinstance(error, (ModelTransportError, ModelAdapterError)) else None + ) + if response is not None: + scope = "provider" + status = response.status_code + category = "MODEL_PROVIDER_ERROR" if 200 <= status < 300 else "MODEL_HTTP_ERROR" + if status in (401, 403): + category = "MODEL_AUTHENTICATION_ERROR" + elif status == 404: + category = "MODEL_NOT_FOUND" + elif status == 429: + category = "MODEL_RATE_LIMITED" + elif 500 <= status < 600: + category = "MODEL_SERVICE_UNAVAILABLE" + elif status in (400, 422): + category = "MODEL_REQUEST_INVALID" + try: + body = parse_json(response.body.decode("utf-8")) + code = ( + body.get("error", {}).get("code") + if isinstance(body, dict) and isinstance(body.get("error"), dict) + else None + ) + if isinstance(code, str) and code in _PROVIDER_CODES: + provider_code = code + mapped = _PROVIDER_CODES[code] + if mapped != "MODEL_RATE_LIMITED" or status == 429 or 500 <= status < 600 or 200 <= status < 300: + category = mapped + except (ValueError, TypeError, UnicodeError, RecursionError): + pass + if category == "MODEL_PROTOCOL_ERROR" and response is None: + # Keep only trusted diagnostic codes; provider bodies and exception text stay private. + source_code = error.category if isinstance(error, (ModelAdapterError, ModelTransportError)) else None + code = source_code if isinstance(source_code, str) and source_code in _PROTOCOL_DIAGNOSTICS else "UNCLASSIFIED" + logger.warning("event=model_protocol_error protocol_code=%s request_id=%s", code, request_id, + extra={"protocol_code": code, "request_id": request_id}) + retryable, message = _CATEGORIES[category] + return ModelError( + category=category, + scope=scope, + retryable=retryable, + will_retry=will_retry and retryable and attempt < max_attempts, + attempt=attempt, + max_attempts=max_attempts, + provider_code=provider_code, + safe_message=message, + request_id=request_id, + ) diff --git a/sdk/nexent/core/model_service/http_contracts.py b/sdk/nexent/core/model_service/http_contracts.py new file mode 100644 index 0000000000..cc8d73d7f8 --- /dev/null +++ b/sdk/nexent/core/model_service/http_contracts.py @@ -0,0 +1,125 @@ +"""HTTP boundary contracts; credentials are resolved only by the sender.""" + +import json +import re +from dataclasses import dataclass, field +from types import MappingProxyType +from urllib.parse import unquote, urlsplit, urlunsplit + +from nexent.core.model_service.profiles.resolved_profile import CredentialReference + + +CONTROL_HEADERS = frozenset({"host", "content-length", "transfer-encoding", "connection"}) +AUTH_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie", "x-api-key", "api-key"}) +HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") + + +def validate_headers(headers: dict[str, str], *, credentials: bool = False) -> dict[str, str]: + """Reject invalid/control headers without exposing their values.""" + if not isinstance(headers, dict): + raise ValueError("Invalid model HTTP headers") + result = {} + for name, value in headers.items(): + if ( + not isinstance(name, str) + or not HEADER_NAME.fullmatch(name) + or not isinstance(value, str) + or any(ord(character) < 32 or ord(character) >= 127 for character in value) + or name.lower() in result + or name.lower() in CONTROL_HEADERS + or (not credentials and name.lower() in AUTH_HEADERS) + ): + raise ValueError("Invalid model HTTP headers") + result[name.lower()] = value + return result + + +def model_request_url(base_url: str, relative_path: str) -> str: + """Preserve the API root and query without guessing a provider prefix.""" + if not isinstance(base_url, str) or not isinstance(relative_path, str): + raise ValueError("Invalid model HTTP URL") + if any(character.isspace() or ord(character) < 32 for character in base_url + relative_path): + raise ValueError("Invalid model HTTP URL") + try: + base, relative = urlsplit(base_url), urlsplit(relative_path) + invalid_port = base.port is not None and not 0 < base.port < 65536 + except ValueError: + raise ValueError("Invalid model HTTP URL") from None + if ( + base.scheme not in {"http", "https"} + or not base.hostname + or base.username is not None + or base.password is not None + or base.fragment + or invalid_port + or relative.scheme + or relative.netloc + or relative.query + or relative.fragment + or not relative.path + or relative.path.startswith("//") + or "\\" in unquote(relative.path) + or any(part in {"", ".", ".."} for part in unquote(relative.path).lstrip("/").split("/")) + ): + raise ValueError("Invalid model HTTP URL") + return urlunsplit( + (base.scheme, base.netloc, base.path.rstrip("/") + "/" + relative.path.lstrip("/"), base.query, "") + ) + + +@dataclass(frozen=True) +class PreparedHttpRequest: + """Store a detached serialized body, never a mutable caller-owned payload.""" + + request_id: str + method: str + base_url: str = field(repr=False) + relative_path: str + headers: dict[str, str] = field(repr=False) + credential_ref: CredentialReference + json_body: dict = field(repr=False) + timeout_seconds: float + ssl_verify: bool + stream_protocol: str | None = None + url: str = field(init=False, repr=False) + body_bytes: bytes = field(init=False, repr=False) + + def __post_init__(self): + if ( + not isinstance(self.request_id, str) + or not self.request_id.strip() + or self.method not in {"POST", "GET"} + or type(self.timeout_seconds) not in {int, float} + or not 0 < self.timeout_seconds < float("inf") + or type(self.ssl_verify) is not bool + or self.stream_protocol not in {None, "sse"} + or not isinstance(self.credential_ref, CredentialReference) + or not isinstance(self.json_body, dict) + ): + raise ValueError("Invalid prepared model HTTP request") + try: + body = json.dumps(self.json_body, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode( + "utf-8" + ) + except (TypeError, ValueError, UnicodeError, RecursionError): + raise ValueError("Invalid model HTTP body") from None + object.__setattr__(self, "body_bytes", body) + object.__setattr__(self, "json_body", MappingProxyType(json.loads(body))) + object.__setattr__(self, "headers", MappingProxyType(validate_headers(self.headers))) + object.__setattr__(self, "url", model_request_url(self.base_url, self.relative_path)) + + +@dataclass(frozen=True) +class RawHttpResponse: + status_code: int + headers: dict[str, str] = field(repr=False) + body: bytes = field(repr=False) + + +class ModelTransportError(Exception): + """Safe common error with private response bytes available to adapters.""" + + def __init__(self, category: str, *, response: RawHttpResponse | None = None): + self.category = category + self.response = response + super().__init__(category) diff --git a/sdk/nexent/core/model_service/http_sender.py b/sdk/nexent/core/model_service/http_sender.py new file mode 100644 index 0000000000..b88866ae4f --- /dev/null +++ b/sdk/nexent/core/model_service/http_sender.py @@ -0,0 +1,296 @@ +"""One-attempt sync/async HTTP sender with owned resource lifetime.""" + +import asyncio +import socket +import ssl +import threading +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import asynccontextmanager, contextmanager + +import httpx + +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.http_contracts import ( + ModelTransportError, + PreparedHttpRequest, + RawHttpResponse, + validate_headers, +) +from nexent.core.model_service.profiles.resolved_profile import CredentialReference +from nexent.core.model_service.sse import SSEByteDecoder, SSEDecodeError, SSEEvent + + +CredentialResolver = Callable[[CredentialReference], dict[str, str]] + + +def _check_cancelled(scope: RunCancellationScope | None) -> None: + if scope is not None and scope.cancelled: + raise RunTerminated() + + +class HttpRequestSender: + """No retries, provider policy, environment reads or tool execution here.""" + + def __init__( + self, + resolve_credentials: CredentialResolver, + *, + max_response_bytes: int = 32 * 1024 * 1024, + max_error_bytes: int = 1024 * 1024, + max_event_bytes: int = 1024 * 1024, + ): + if any( + type(limit) is not int or limit <= 0 for limit in (max_response_bytes, max_error_bytes, max_event_bytes) + ): + raise ValueError("HTTP response limits must be positive integers") + self._resolve_credentials = resolve_credentials + self._response_limit = max_response_bytes + self._error_limit = max_error_bytes + self._event_limit = max_event_bytes + + def _headers(self, request: PreparedHttpRequest) -> dict[str, str]: + try: + credentials = validate_headers(self._resolve_credentials(request.credential_ref), credentials=True) + except Exception: + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") from None + if credentials.keys() & request.headers.keys(): + raise ModelTransportError("CREDENTIAL_HEADER_CONFLICT") + return {"content-type": "application/json", **request.headers, **credentials} + + def freeze_credentials(self, request: PreparedHttpRequest) -> "HttpRequestSender": + """Resolve once for a logical call; retries retain the same credential headers.""" + try: + headers = validate_headers(self._resolve_credentials(request.credential_ref), credentials=True) + except Exception: + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") from None + if headers.keys() & request.headers.keys(): + raise ModelTransportError("CREDENTIAL_HEADER_CONFLICT") + + def resolve(reference): + if reference != request.credential_ref: + raise ValueError("Credential reference does not match the frozen request") + return dict(headers) + + return HttpRequestSender( + resolve, + max_response_bytes=self._response_limit, + max_error_bytes=self._error_limit, + max_event_bytes=self._event_limit, + ) + + @staticmethod + def _failure(error: httpx.HTTPError) -> ModelTransportError: + cause = error + for _ in range(8): + if isinstance(cause, ssl.SSLError): + return ModelTransportError("HTTP_TLS_ERROR") + cause = cause.__cause__ or cause.__context__ + if cause is None: + break + category = "HTTP_TIMEOUT" if isinstance(error, httpx.TimeoutException) else "HTTP_NETWORK_ERROR" + return ModelTransportError(category) + + @staticmethod + def _metadata(response: httpx.Response, body: bytes) -> RawHttpResponse: + return RawHttpResponse(response.status_code, dict(response.headers), body) + + def _read(self, response: httpx.Response, limit: int) -> bytes: + parts, size = [], 0 + for part in response.iter_bytes(): + size += len(part) + if size > limit: + raise ModelTransportError("HTTP_RESPONSE_TOO_LARGE") + parts.append(part) + return b"".join(parts) + + async def _aread(self, response: httpx.Response, limit: int) -> bytes: + parts, size = [], 0 + async for part in response.aiter_bytes(): + size += len(part) + if size > limit: + raise ModelTransportError("HTTP_RESPONSE_TOO_LARGE") + parts.append(part) + return b"".join(parts) + + @contextmanager + def _response(self, request: PreparedHttpRequest, scope: RunCancellationScope | None): + _check_cancelled(scope) + headers = self._headers(request) + client = httpx.Client(verify=request.ssl_verify, timeout=request.timeout_seconds, trust_env=False) + lock = threading.Lock() + closed = False + streams = [] + + def shutdown_stream(stream): + try: + connection = stream.get_extra_info("socket") + if isinstance(connection, socket.socket): + connection.shutdown(socket.SHUT_RDWR) + except (AttributeError, OSError): + pass + + def trace(event, info): + if event not in {"connection.connect_tcp.complete", "connection.start_tls.complete"}: + return + stream = info.get("return_value") + if stream is None: + return + with lock: + already_closed = closed + if not already_closed: + streams.append(stream) + if already_closed: + shutdown_stream(stream) + stream.close() + _check_cancelled(scope) + + def close_once(): + nonlocal closed + with lock: + if closed: + return + closed = True + owned_streams = tuple(streams) + for stream in owned_streams: + shutdown_stream(stream) + client.close() + + token = scope.register_closer(close_once) if scope else None + try: + _check_cancelled(scope) + with client.stream( + request.method, + request.url, + headers=headers, + content=request.body_bytes, + extensions={"trace": trace}, + ) as response: + _check_cancelled(scope) + yield response + except httpx.HTTPError as error: + _check_cancelled(scope) + raise self._failure(error) from None + finally: + if scope and token: + scope.unregister_closer(token) + close_once() + + @asynccontextmanager + async def _aresponse(self, request: PreparedHttpRequest, scope: RunCancellationScope | None): + _check_cancelled(scope) + headers = self._headers(request) + loop, task = asyncio.get_running_loop(), asyncio.current_task() + + active = True + + def cancel_if_active(): + if active: + task.cancel() + + def cancel_task(): + loop.call_soon_threadsafe(cancel_if_active) + + token = scope.register_closer(cancel_task) if scope else None + try: + _check_cancelled(scope) + async with httpx.AsyncClient( + verify=request.ssl_verify, timeout=request.timeout_seconds, trust_env=False + ) as client: + async with client.stream( + request.method, request.url, headers=headers, content=request.body_bytes + ) as response: + _check_cancelled(scope) + yield response + except asyncio.CancelledError: + _check_cancelled(scope) + raise + except httpx.HTTPError as error: + _check_cancelled(scope) + raise self._failure(error) from None + finally: + active = False + if scope and token: + scope.unregister_closer(token) + + def invoke(self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None) -> RawHttpResponse: + with self._response(request, scope) as response: + body = self._read(response, self._response_limit if response.is_success else self._error_limit) + _check_cancelled(scope) + raw = self._metadata(response, body) + if not response.is_success: + raise ModelTransportError("HTTP_STATUS_ERROR", response=raw) + return raw + + async def ainvoke( + self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None + ) -> RawHttpResponse: + async with self._aresponse(request, scope) as response: + body = await self._aread(response, self._response_limit if response.is_success else self._error_limit) + _check_cancelled(scope) + raw = self._metadata(response, body) + if not response.is_success: + raise ModelTransportError("HTTP_STATUS_ERROR", response=raw) + return raw + + def _stream_decoder(self, response: httpx.Response, request: PreparedHttpRequest) -> SSEByteDecoder: + if ( + request.stream_protocol != "sse" + or response.headers.get("content-type", "").split(";")[0].strip() != "text/event-stream" + ): + raise ModelTransportError("HTTP_STREAM_FORMAT_INVALID") + return SSEByteDecoder(max_event_bytes=self._event_limit) + + @contextmanager + def stream( + self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None + ) -> Iterator[Iterator[SSEEvent]]: + with self._response(request, scope) as response: + if not response.is_success: + raise ModelTransportError( + "HTTP_STATUS_ERROR", response=self._metadata(response, self._read(response, self._error_limit)) + ) + decoder = self._stream_decoder(response, request) + + def events(): + try: + for text in response.iter_bytes(): + _check_cancelled(scope) + for event in decoder.feed(text): + _check_cancelled(scope) + yield event + decoder.finish() + except SSEDecodeError as error: + raise ModelTransportError(error.category) from None + + iterator = events() + try: + yield iterator + finally: + iterator.close() + + @asynccontextmanager + async def astream( + self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None + ) -> AsyncIterator[AsyncIterator[SSEEvent]]: + async with self._aresponse(request, scope) as response: + if not response.is_success: + body = await self._aread(response, self._error_limit) + raise ModelTransportError("HTTP_STATUS_ERROR", response=self._metadata(response, body)) + decoder = self._stream_decoder(response, request) + + async def events(): + try: + async for text in response.aiter_bytes(): + _check_cancelled(scope) + for event in decoder.feed(text): + _check_cancelled(scope) + yield event + decoder.finish() + except SSEDecodeError as error: + raise ModelTransportError(error.category) from None + + iterator = events() + try: + yield iterator + finally: + await iterator.aclose() diff --git a/sdk/nexent/core/model_service/parameter_transforms.py b/sdk/nexent/core/model_service/parameter_transforms.py new file mode 100644 index 0000000000..c66f971ef8 --- /dev/null +++ b/sdk/nexent/core/model_service/parameter_transforms.py @@ -0,0 +1,32 @@ +"""Executable versioned parameter conversions shared by validation and adapters.""" + +from types import MappingProxyType + +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, _json_copy + + +def _thinking_toggle(value): + if type(value) is not bool: + raise ParameterValidationError("PARAMETER_TRANSFORM_VALUE_INVALID") + return "enabled" if value else "disabled" + + +_TRANSFORMS = MappingProxyType({ + ("identity_v1", 1): _json_copy, + ("thinking_toggle_v1", 1): _thinking_toggle, +}) + + +def parameter_transform_references(): + """Advertise only implementations available in this process.""" + return tuple(VersionedReference(ref=ref, version=version) for ref, version in _TRANSFORMS) + + +def transform_parameter(binding, value): + """Transform a detached value; never infer a rule from provider or model names.""" + key = (binding["transform_ref"], binding["transform_version"]) + transform = _TRANSFORMS.get(key) + if transform is None: + raise ParameterValidationError("PARAMETER_TRANSFORM_UNREGISTERED") + return transform(_json_copy(value)) diff --git a/sdk/nexent/core/model_service/profiles/LICENSE b/sdk/nexent/core/model_service/profiles/LICENSE new file mode 100644 index 0000000000..9ef000844a --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 models.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/nexent/core/model_service/profiles/README.md b/sdk/nexent/core/model_service/profiles/README.md new file mode 100644 index 0000000000..cd20470564 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/README.md @@ -0,0 +1,60 @@ +# Nexent model profile source catalog + +## Source and scope + +Selected authored TOML files are copied byte-for-byte from +[models.dev](https://github.com/anomalyco/models.dev), commit +`e69e1844210e5cfe7c7e7f68d57328bc7451a51c`. +The original MIT copyright and permission notice is retained in [LICENSE](LICENSE). + +The initial inventory contains 21 model files, 21 provider-model files and three +provider files for Alibaba China, Volcengine and Volcengine Coding Plan. It covers +selected recent Qwen, GLM, DeepSeek and MiniMax models. This is source material, +not a verified support list. Pricing remains reference data; this change does +not implement cost estimation. + +## File ownership + +- `models/` and `providers/` retain upstream paths, spelling, comments and bytes. +- `manifest.toml` pins source kinds, paths, hashes and the upstream revision. +- Nexent normalization and API binding rules belong under `rules/` separately; + do not silently rewrite upstream files. +- Source references are provenance. Saved outlets retain their resolved + configuration and do not recompose it when the catalog changes. + +## Updating the selection + +1. Select one upstream commit and inspect its authored schema and generator. +2. Copy reviewed files and their complete `base_model` dependencies. +3. Preserve original bytes and paths; update manifest hashes and revision. +4. Review every field against the versioned Nexent mapping rules, including + fields retained as references or explicitly ignored. +5. Run integrity, parser, normalization, composition and installed-package + checks. Run the reusable real-model matrix before declaring support. +6. Keep deleted upstream entries locally unless deliberately removed in a Nexent + release; do not modify saved outlets automatically. + +No upstream TypeScript project, online synchronization or npm dependency is +required at runtime. Model weight licenses and service terms remain separate +from the license of these descriptive files. + + +## Nexent supplements and complete catalog loading + +`resolver.load_source_catalog()` reads source facts only. +`resolver.load_profile_catalog()` additionally validates `rules/manifest.toml`, +reads exact Nexent supplement declarations and publishes immutable atomic views. +The manifest pins each supplement file's digest separately from upstream data. + +The first supplement selection covers Alibaba China `qwen3.5-plus`: explicit +model linkage, traditional instruction mapping, thinking toggle and the +`reasoning_budget_tokens` to `/thinking_budget` binding. The copied maximum is +81,920 tokens; the lower bound remains unknown, and no numeric default is +invented. API binding declarations require a trusted implementation registry +when composing a usable policy. Catalog inclusion does not certify a live +provider endpoint or register an unimplemented transport. + +Keep upstream TOML unchanged. Put model rules under `rules/models/` and provider +or exact provider-model rules under `rules/providers/`; update their manifest +hashes when editing. Use the optional `json_fields` table only for JSON-only +values such as explicit null, never as a string alias or arbitrary path patch. diff --git a/sdk/nexent/core/model_service/profiles/__init__.py b/sdk/nexent/core/model_service/profiles/__init__.py new file mode 100644 index 0000000000..b32bda4f07 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/__init__.py @@ -0,0 +1 @@ +"""Local model profile definitions, loading and resolution.""" diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/__init__.py b/sdk/nexent/core/model_service/profiles/_models_dev/__init__.py new file mode 100644 index 0000000000..363963c931 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/__init__.py @@ -0,0 +1 @@ +"""Private parsing of the pinned models.dev authored source format.""" diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py b/sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py new file mode 100644 index 0000000000..624ebcd3ca --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py @@ -0,0 +1,136 @@ +"""Pinned authored base_model inheritance with per-field source ownership.""" + +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError, _freeze + + +_EXCLUDED_BASE_FIELDS = frozenset({"id", "license", "links", "weights", "benchmarks"}) + + +@dataclass(frozen=True) +class FieldOrigin: + """Source ownership before the versioned semantic mapping is applied.""" + + source_kind: str + file_path: str + source_path: str + content_sha256: str + + +@dataclass(frozen=True) +class EffectiveSourceModel: + """Read-only source result, distinct from a resolved Nexent outlet.""" + + document: SourceDocument + data: Mapping[str, object] + field_origins: Mapping[str, FieldOrigin] + dependencies: tuple[SourceDocument, ...] + omitted_paths: tuple[str, ...] + + +def _error(category: str, document: SourceDocument, source_path: str) -> SourceReadError: + error = SourceReadError(category, document.file_path, document.kind) + error.source_path = source_path + error.rule_id = "source_inheritance_v1" + return error + + +def _thaw(value: object) -> object: + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_thaw(item) for item in value] + return value + + +def _collect_origins(value: object, path: str, document: SourceDocument, origins: dict) -> None: + if isinstance(value, Mapping) and value: + for key, item in value.items(): + _collect_origins(item, f"{path}.{key}" if path else key, document, origins) + elif isinstance(value, (tuple, list)) and value: + for index, item in enumerate(value): + _collect_origins(item, f"{path}[{index}]", document, origins) + elif path: + origins[path] = FieldOrigin(document.kind, document.file_path, path, document.content_sha256) + + +def _clear_origins(origins: dict, path: str) -> None: + for key in tuple(origins): + if key == path or key.startswith(path + ".") or key.startswith(path + "["): + del origins[key] + + +def _overlay(target: dict, updates: Mapping, path: str, document: SourceDocument, origins: dict) -> None: + for key, value in updates.items(): + field_path = f"{path}.{key}" if path else key + if isinstance(target.get(key), dict) and isinstance(value, Mapping): + if value: + origins.pop(field_path, None) + _overlay(target[key], value, field_path, document, origins) + else: + _clear_origins(origins, field_path) + target[key] = _thaw(value) + _collect_origins(value, field_path, document, origins) + + +def _omit(target: dict, path: str, origins: dict) -> None: + parts = path.split(".") + current = target + parents = [] + for part in parts[:-1]: + child = current.get(part) + if not isinstance(child, dict): + return + parents.append((current, part)) + current = child + if parts[-1] not in current: + return + del current[parts[-1]] + _clear_origins(origins, path) + for parent, key in reversed(parents): + if parent[key]: + break + del parent[key] + + +def resolve_source_model(document: SourceDocument, sources: tuple[SourceDocument, ...]) -> EffectiveSourceModel: + """Use only explicit base references; never infer a model from its name.""" + if document.kind not in ("model", "provider_model"): + raise _error("SOURCE_MODEL_KIND_INVALID", document, "") + models = {} + for source in sources: + if source.kind == "model": + if source.key in models: + raise _error("SOURCE_DUPLICATE", source, "") + models[source.key] = source + + origins = {} + dependencies = (document,) + data = {} + if "base_model" in document.data: + base_key = document.data["base_model"] + if document.kind != "provider_model" or not isinstance(base_key, str) or not base_key.strip(): + raise _error("SOURCE_BASE_REFERENCE_INVALID", document, "base_model") + base = models.get(base_key) + if base is None: + raise _error("SOURCE_BASE_MODEL_MISSING", document, "base_model") + if "base_model" in base.data: + raise _error("SOURCE_BASE_REFERENCE_INVALID", base, "base_model") + data = _thaw({key: value for key, value in base.data.items() if key not in _EXCLUDED_BASE_FIELDS}) + _collect_origins(data, "", base, origins) + dependencies = (base, document) + elif "base_model_omit" in document.data: + raise _error("SOURCE_OMIT_WITHOUT_BASE", document, "base_model_omit") + + omissions = document.data.get("base_model_omit", ()) + if not isinstance(omissions, (tuple, list)) or any( + not isinstance(path, str) or any(not part for part in path.split(".")) for path in omissions + ): + raise _error("SOURCE_OMIT_INVALID", document, "base_model_omit") + updates = {key: value for key, value in document.data.items() if key not in ("base_model", "base_model_omit")} + _overlay(data, updates, "", document, origins) + for path in omissions: + _omit(data, path, origins) + return EffectiveSourceModel(document, _freeze(data), _freeze(origins), dependencies, tuple(omissions)) diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py b/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py new file mode 100644 index 0000000000..a18eb00baa --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py @@ -0,0 +1,191 @@ +"""Versioned source dispositions, separate from runtime API field bindings.""" + +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + +from pydantic import Field, ValidationError + +from nexent.core.model_service.profiles._models_dev.inheritance import EffectiveSourceModel, FieldOrigin +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError, _freeze +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +class SourceMappingRule(ProfileContract): + """One explicitly scoped authored field disposition.""" + + rule_id: NonBlankString + rule_version: int = Field(ge=1, le=1) + source_schema_ref: Literal["models_dev_authored_v1"] + source_kind: Literal["model", "provider", "provider_model"] + source_path: NonBlankString + source_type: Literal["boolean", "string", "integer", "number", "number_or_string", "object", "array"] + target_path: NonBlankString + transform_ref: Literal["identity_v1", "boolean_capability_v1", "zero_unknown_tokens_v1"] + missing_policy: Literal["omit", "unknown"] + special_value_policy: Literal["preserve", "zero_is_unknown", "strict_boolean"] + disposition: Literal["map", "reference"] + evidence: NonBlankString + + +class SourceMappingRegistry(ProfileContract): + mapping_id: Literal["models_dev_source_v1"] + mapping_version: int = Field(ge=1, le=1) + rules: tuple[SourceMappingRule, ...] = Field(min_length=1) + + +@dataclass(frozen=True) +class MappedFieldOrigin: + source: FieldOrigin + rule_id: str + rule_version: int + target_path: str + + +@dataclass(frozen=True) +class MappedSourceFields: + """Canonical fields before supplements and runtime profile composition.""" + + source: EffectiveSourceModel | SourceDocument + mapping_id: str + mapping_version: int + values: Mapping[str, object] + field_origins: Mapping[str, MappedFieldOrigin] + + +def _error(category: str, file_path: str, kind: str, path: str = "") -> SourceReadError: + error = SourceReadError(category, file_path, kind) + error.source_path = path + error.rule_id = "models_dev_source_v1" + return error + + +def load_mapping_rules(path: Path) -> SourceMappingRegistry: + """Load the explicitly selected local registry; unknown rules fail closed.""" + try: + data = path.read_bytes() + except OSError: + raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping") from None + return parse_mapping_rules(data, path.name) + + +def parse_mapping_rules(data: bytes, file_path: str) -> SourceMappingRegistry: + """Parse a captured rule snapshot; revision hashing uses these same bytes.""" + try: + raw = tomllib.loads(data.decode("utf-8")) + if isinstance(raw.get("rules"), list): + raw["rules"] = tuple(raw["rules"]) + registry = SourceMappingRegistry.model_validate(raw) + except (UnicodeDecodeError, tomllib.TOMLDecodeError, ValidationError): + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping") from None + identities = set() + rule_ids = set() + targets = set() + for rule in registry.rules: + identity = (rule.source_kind, rule.source_path) + if identity in identities or rule.rule_id in rule_ids: + raise _error("SOURCE_MAPPING_DUPLICATE", file_path, "mapping", rule.source_path) + identities.add(identity) + rule_ids.add(rule.rule_id) + target_identity = (rule.source_kind, rule.target_path) + if target_identity in targets: + raise _error("SOURCE_TARGET_CONFLICT", file_path, "mapping", rule.source_path) + targets.add(target_identity) + if ( + not re.fullmatch( + r"/[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?(?:/[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?)*", rule.target_path + ) + or not re.fullmatch( + r"[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?(?:\.[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?)*", rule.source_path + ) + or rule.source_path.count("[]") != rule.target_path.count("[]") + ): + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping", rule.source_path) + if (rule.transform_ref == "boolean_capability_v1" and rule.source_type != "boolean") or ( + rule.transform_ref == "zero_unknown_tokens_v1" and rule.source_type != "integer" + ): + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping", rule.source_path) + expected_policy = { + "identity_v1": "preserve", + "boolean_capability_v1": "strict_boolean", + "zero_unknown_tokens_v1": "zero_is_unknown", + }[rule.transform_ref] + if rule.special_value_policy != expected_policy: + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping", rule.source_path) + for parent in registry.rules: + if parent.source_type in ("object", "array"): + continue + for child in registry.rules: + if ( + parent.source_kind == "provider_model" + and parent.source_path == "interleaved" + and child.source_kind == parent.source_kind + and child.source_path == "interleaved.field" + ): + continue + if child.source_kind == parent.source_kind and child.target_path.startswith(parent.target_path + "/"): + raise _error("SOURCE_TARGET_CONFLICT", file_path, "mapping", child.source_path) + return registry + + +def _leaves(value: object, path: str = ""): + if isinstance(value, Mapping) and value: + for key, item in value.items(): + yield from _leaves(item, f"{path}.{key}" if path else key) + elif isinstance(value, (tuple, list)) and value: + for index, item in enumerate(value): + yield from _leaves(item, f"{path}[{index}]") + elif path: + yield path, value + + +def _typed(value: object, source_type: str) -> bool: + return { + "boolean": type(value) is bool, + "string": isinstance(value, str), + "integer": type(value) is int, + "number": type(value) in (int, float), + "number_or_string": type(value) in (int, float) or isinstance(value, str), + "object": isinstance(value, Mapping), + "array": isinstance(value, (tuple, list)), + }[source_type] + + +def map_source_fields( + source: EffectiveSourceModel | SourceDocument, registry: SourceMappingRegistry +) -> MappedSourceFields: + """Map every validated leaf explicitly; no field-name aliases or API paths.""" + document = source.document if isinstance(source, EffectiveSourceModel) else source + rules = {rule.source_path: rule for rule in registry.rules if rule.source_kind == document.kind} + values = {} + origins = {} + for path, value in _leaves(source.data): + pattern = re.sub(r"\[\d+\]", "[]", path) + rule = rules.get(pattern) + if rule is None: + raise _error("SOURCE_FIELD_UNHANDLED", document.file_path, document.kind, path) + if not _typed(value, rule.source_type): + raise _error("SOURCE_VALUE_INVALID", document.file_path, document.kind, path) + indexes = iter(re.findall(r"\[(\d+)\]", path)) + target = re.sub(r"\[\]", lambda _: "/" + next(indexes), rule.target_path) + if target in values: + raise _error("SOURCE_TARGET_CONFLICT", document.file_path, document.kind, path) + if rule.transform_ref == "boolean_capability_v1": + value = "supported" if value else "unsupported" + elif rule.transform_ref == "zero_unknown_tokens_v1" and value == 0: + value = None + values[target] = value + origins[target] = MappedFieldOrigin( + source.field_origins[path] + if isinstance(source, EffectiveSourceModel) + else FieldOrigin(document.kind, document.file_path, path, document.content_sha256), + rule.rule_id, + rule.rule_version, + target, + ) + for path, rule in rules.items(): + if "[]" not in path and rule.missing_policy == "unknown" and rule.target_path not in values: + values[rule.target_path] = "unknown" if rule.transform_ref == "boolean_capability_v1" else None + return MappedSourceFields(source, registry.mapping_id, registry.mapping_version, _freeze(values), _freeze(origins)) diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/reader.py b/sdk/nexent/core/model_service/profiles/_models_dev/reader.py new file mode 100644 index 0000000000..6bef70179c --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/reader.py @@ -0,0 +1,155 @@ +"""Read the complete declared source inventory before semantic normalization.""" + +import hashlib +import tomllib +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Literal, Mapping + +from pydantic import Field, ValidationError + +from nexent.core.model_service.profiles.contracts import AtomicProfileReference, NonBlankString, ProfileContract + + +SourceKind = Literal["model", "provider", "provider_model"] + + +class SourceEntry(ProfileContract): + """One source file whose bytes are pinned by the catalog manifest.""" + + kind: SourceKind + path: NonBlankString + content_sha256: NonBlankString + + +class SourceManifest(ProfileContract): + """Versioned source inventory; mapping and runtime API bindings are separate.""" + + schema_version: int = Field(ge=1, le=1) + source_schema_ref: Literal["models_dev_authored_v1"] + upstream_url: Literal["https://github.com/anomalyco/models.dev"] + upstream_revision: NonBlankString + sources: list[SourceEntry] = Field(min_length=1) + + +class SourceReadError(ValueError): + """Sanitized source diagnostic without embedding file contents or OS errors.""" + + def __init__(self, category: str, file_path: str, source_kind: str = "manifest"): + self.category = category + self.file_path = file_path + self.source_kind = source_kind + self.source_path = "" + self.rule_id = "source_inventory_v1" + super().__init__(f"{category}: {source_kind}: {file_path}") + + +@dataclass(frozen=True) +class SourceDocument: + """Deeply read-only authored document, not a resolved runtime profile.""" + + kind: SourceKind + key: str + file_path: str + content_sha256: str + data: Mapping[str, object] + + +def _freeze(value: object) -> object: + if isinstance(value, dict): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze(item) for item in value) + return value + + +def _read(root: Path, path: str, kind: str) -> bytes: + try: + candidate = (root / path).resolve() + if not candidate.is_relative_to(root): + raise SourceReadError("SOURCE_PATH_OUTSIDE_ROOT", path, kind) + return candidate.read_bytes() + except (OSError, RuntimeError): + raise SourceReadError("SOURCE_FILE_UNAVAILABLE", path, kind) from None + + +def _parse(data: bytes, path: str, kind: str) -> dict: + try: + return tomllib.loads(data.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + raise SourceReadError("SOURCE_TOML_INVALID", path, kind) from None + + +def _key(entry: SourceEntry) -> str: + parts = entry.path.split("/") + if entry.kind == "model" and len(parts) >= 3 and parts[0] == "models": + return entry.path[len("models/") : -len(".toml")] + if entry.kind == "provider" and len(parts) == 3 and parts[0] == "providers" and parts[2] == "provider.toml": + return parts[1] + if entry.kind == "provider_model" and len(parts) >= 4 and parts[0] == "providers" and parts[2] == "models": + return parts[1] + "/" + "/".join(parts[3:])[: -len(".toml")] + raise SourceReadError("SOURCE_KIND_PATH_MISMATCH", entry.path, entry.kind) + + +@dataclass(frozen=True) +class SourceInventory: + """Metadata from the same manifest bytes used to validate source documents.""" + + upstream_revision: str + source_schema_ref: str + manifest_sha256: str + documents: tuple[SourceDocument, ...] + + +def read_inventory(root: Path) -> SourceInventory: + """Fail the whole inventory on errors; do not expose a partial catalog.""" + root = root.resolve() + manifest_bytes = _read(root, "manifest.toml", "manifest") + raw_manifest = _parse(manifest_bytes, "manifest.toml", "manifest") + try: + manifest = SourceManifest.model_validate(raw_manifest) + if len(manifest.upstream_revision) != 40 or any( + char not in "0123456789abcdef" for char in manifest.upstream_revision + ): + raise ValueError("Invalid upstream commit") + except (ValidationError, ValueError): + raise SourceReadError("SOURCE_MANIFEST_INVALID", "manifest.toml") from None + + documents = [] + identities = set() + paths = set() + for entry in manifest.sources: + try: + AtomicProfileReference( + key=entry.path, + catalog_revision=manifest.upstream_revision, + file_path=entry.path, + content_sha256=entry.content_sha256, + ) + except ValidationError: + raise SourceReadError("SOURCE_REFERENCE_INVALID", entry.path, entry.kind) from None + if not entry.path.endswith(".toml") or entry.path.split("/")[-1] == ".toml": + raise SourceReadError("SOURCE_KIND_PATH_MISMATCH", entry.path, entry.kind) + key = _key(entry) + identity = (entry.kind, key) + if entry.path in paths or identity in identities: + raise SourceReadError("SOURCE_DUPLICATE", entry.path, entry.kind) + paths.add(entry.path) + identities.add(identity) + data = _read(root, entry.path, entry.kind) + if hashlib.sha256(data).hexdigest() != entry.content_sha256: + raise SourceReadError("SOURCE_DIGEST_MISMATCH", entry.path, entry.kind) + parsed = _parse(data, entry.path, entry.kind) + documents.append(SourceDocument(entry.kind, key, entry.path, entry.content_sha256, _freeze(parsed))) + return SourceInventory( + manifest.upstream_revision, + manifest.source_schema_ref, + hashlib.sha256(manifest_bytes).hexdigest(), + tuple(documents), + ) + + +def read_sources(root: Path) -> tuple[SourceDocument, ...]: + """Return complete authored documents without changing the existing interface.""" + return read_inventory(root).documents diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/validation.py b/sdk/nexent/core/model_service/profiles/_models_dev/validation.py new file mode 100644 index 0000000000..c2feead617 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/validation.py @@ -0,0 +1,480 @@ +"""Strict selected authored schemas pinned to models.dev e69e184. + +No provider request binding or capability defaults are inferred here. +""" + +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass +from urllib.parse import urlsplit + +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError + + +@dataclass(frozen=True) +class ObjectSchema: + fields: Mapping[str, object] + required: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ArraySchema: + item: object + + +def _error(document: SourceDocument, path: str, category: str = "SOURCE_VALUE_INVALID") -> SourceReadError: + error = SourceReadError(category, document.file_path, document.kind) + error.source_path = path + error.rule_id = "models_dev_authored_v1" + return error + + +def _date(value: str) -> bool: + if not re.fullmatch(r"\d{4}-\d{2}(-\d{2})?", value): + return False + parts = [int(part) for part in value.split("-")] + year, month = parts[:2] + if not 1 <= month <= 12: + return False + if len(parts) == 2: + return True + leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + days = (31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + return 1 <= parts[2] <= days[month - 1] + + +def _validate(value: object, schema: object, document: SourceDocument, path: str, partial: bool = False) -> None: + if isinstance(schema, ObjectSchema): + if not isinstance(value, Mapping): + raise _error(document, path) + for key, item in value.items(): + field_path = f"{path}.{key}" if path else key + if key not in schema.fields: + raise _error(document, field_path, "SOURCE_FIELD_UNHANDLED") + _validate(item, schema.fields[key], document, field_path, partial) + if not partial: + for key in schema.required: + if key not in value: + raise _error(document, f"{path}.{key}" if path else key, "SOURCE_FIELD_REQUIRED") + return + if isinstance(schema, ArraySchema): + if not isinstance(value, (list, tuple)): + raise _error(document, path) + for index, item in enumerate(value): + _validate(item, schema.item, document, f"{path}[{index}]", partial) + return + valid = False + if isinstance(schema, frozenset): + valid = isinstance(value, str) and value in schema + elif schema == "boolean": + valid = type(value) is bool + elif schema == "string": + valid = isinstance(value, str) + elif schema == "nonempty": + valid = isinstance(value, str) and len(value) > 0 + elif schema == "date": + valid = isinstance(value, str) and _date(value) + elif schema == "url": + if isinstance(value, str): + try: + parsed = urlsplit(value) + valid = bool( + parsed.scheme and (parsed.netloc if parsed.scheme in ("http", "https", "ftp") else parsed.path) + ) + valid = valid and not any(character.isspace() for character in value) + except ValueError: + valid = False + elif schema in ("number", "nonnegative", "budget_min", "tokens"): + valid = type(value) is int or (type(value) is float and math.isfinite(value)) + if valid and schema != "number": + valid = value >= (-1 if schema == "budget_min" else 0) + if valid and schema in ("tokens", "budget_min"): + valid = type(value) is int + elif schema == "score": + valid = (type(value) is int or (type(value) is float and math.isfinite(value))) or ( + isinstance(value, str) and bool(value) + ) + elif schema == "interleaved": + if value is True: + return + _validate( + value, + ObjectSchema({"field": frozenset({"reasoning_content", "reasoning_details"})}, ("field",)), + document, + path, + partial, + ) + return + elif schema == "reasoning_option": + if not isinstance(value, Mapping) or value.get("type") not in ("toggle", "effort", "budget_tokens"): + raise _error(document, f"{path}.type") + mode = value["type"] + fields = {"type": frozenset({mode})} + required = ("type",) + if mode == "effort": + fields["values"] = ArraySchema( + frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "default", "null"}) + ) + required += ("values",) + if mode == "budget_tokens": + fields.update(min="budget_min", max="tokens") + _validate(value, ObjectSchema(fields, required), document, path, partial) + if mode == "budget_tokens" and "min" in value and "max" in value and value["min"] > value["max"]: + raise _error(document, f"{path}.min", "SOURCE_CONSTRAINT_INVALID") + return + if not valid: + raise _error(document, path) + + +_MODALITIES = ObjectSchema( + { + "input": ArraySchema(frozenset({"text", "audio", "image", "video", "pdf"})), + "output": ArraySchema(frozenset({"text", "audio", "image", "video", "pdf"})), + }, + ("input", "output"), +) +_LIMIT = ObjectSchema({"context": "tokens", "input": "tokens", "output": "tokens"}, ("context",)) +_LINK = ObjectSchema( + { + "url": "url", + "label": "nonempty", + "type": frozenset({"announcement", "blog", "docs", "license", "model_card", "paper", "weights", "other"}), + }, + ("url",), +) +_WEIGHT = ObjectSchema({"url": "url", "label": "nonempty", "format": "nonempty", "quantization": "nonempty"}, ("url",)) +_BENCHMARK = ObjectSchema( + { + "name": "nonempty", + "score": "score", + "metric": "nonempty", + "harness": "nonempty", + "variant": "nonempty", + "dataset": "nonempty", + "version": "nonempty", + "source": "url", + "date": "date", + }, + ("name", "score"), +) +_COST_FIELDS = dict.fromkeys( + ("input", "output", "reasoning", "cache_read", "cache_write", "input_audio", "output_audio"), "nonnegative" +) +_COST_TIER = ObjectSchema( + {**_COST_FIELDS, "tier": ObjectSchema({"type": frozenset({"context"}), "size": "tokens"}, ("size",))}, + ("input", "output", "tier"), +) +_COST = ObjectSchema({**_COST_FIELDS, "tiers": ArraySchema(_COST_TIER)}, ("input", "output")) +_FAMILIES = frozenset( + ( + "trinity", + "trinity-mini", + "gpt", + "gpt-codex", + "gpt-codex-spark", + "gpt-codex-mini", + "gpt-pro", + "gpt-mini", + "gpt-nano", + "gpt-sol", + "gpt-terra", + "gpt-luna", + "gpt-astra", + "gpt-oss", + "gpt-image", + "o", + "o-mini", + "o-pro", + "claude", + "claude-haiku", + "claude-sonnet", + "claude-opus", + "claude-fable", + "claude-mythos", + "gemini", + "gemini-pro", + "gemini-flash", + "gemini-flash-lite", + "gemini-embedding", + "glm", + "glmv", + "glm-air", + "glm-flash", + "glm-free", + "glm-z", + "llama", + "muse", + "muse-free", + "qwen", + "qwen3.5", + "qwen3.6", + "qwen3.7-plus", + "qwen3.7-max", + "qwen3.8-max", + "qwen-free", + "ornith", + "deepseek", + "deepseek-thinking", + "deepseek-flash", + "deepseek-flash-free", + "deepseek-flash-think", + "phi", + "kimi", + "kimi-k2", + "kimi-k3", + "kimi-free", + "kimi-thinking", + "motif", + "laguna", + "laguna-s", + "mistral", + "mistral-large", + "mistral-medium", + "mistral-small", + "mistral-nemo", + "ministral", + "codestral", + "devstral", + "leanstral", + "pixtral", + "mixtral", + "grok", + "grok-build", + "grok-vision", + "grok-beta", + "gemma", + "nova", + "nova-pro", + "nova-lite", + "nova-micro", + "command", + "command-r", + "command-a", + "command-light", + "north", + "north-free", + "jamba", + "nemotron", + "nemotron-free", + "titan", + "titan-embed", + "minimax", + "minimax-m2.5", + "minimax-m2.7", + "minimax-m3", + "minimax-m3-free", + "minimax-free", + "hunyuan", + "Hy", + "yi", + "granite", + "reka", + "sonar", + "sonar-pro", + "sonar-reasoning", + "sonar-deep-research", + "solar", + "solar-mini", + "solar-pro", + "step", + "text-embedding", + "cohere-embed", + "voyage", + "mistral-embed", + "bge", + "plamo", + "codestral-embed", + "dall-e", + "flux", + "imagen", + "recraft", + "stable-diffusion", + "ideogram", + "dreamshaper", + "sora", + "veo", + "runway", + "dream-machine", + "whisper", + "elevenlabs", + "lyria", + "melotts", + "ernie", + "hermes", + "zephyr", + "openchat", + "starling", + "qvq", + "sherlock", + "pony", + "mercury", + "cogito", + "mimo", + "mimo-pro", + "mimo-omni", + "mimo-v2-pro", + "mimo-v2-omni", + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2.5-free", + "mimo-pro-free", + "mimo-omni-free", + "mimo-flash-free", + "mm-poly", + "longcat", + "magistral", + "magistral-small", + "magistral-medium", + "phoenix", + "lucid", + "agi", + "intellect", + "aura", + "jais", + "sarvam", + "falcon", + "baichuan", + "skywork", + "bart", + "distilbert", + "resnet", + "m2m", + "indictrans", + "llava", + "seed", + "ray", + "tstars", + "rnj", + "hy3", + "hy3-free", + "ling", + "ling-flash-free", + "ring", + "ring-1t-free", + "kat-coder", + "sqlcoder", + "discolm", + "osmosis", + "parakeet", + "nemoretriever", + "nano-banana", + "una-cybertron", + "morph", + "voxtral", + "venice", + "auto", + "model-router", + "fugu", + "sakana-namazu", + "v0", + "tako", + "mai", + "rednote", + "smart-turn", + "qwerky", + "big-pickle", + "chutesai", + "opengvlab", + "tngtech", + "topazlabs", + "unsloth", + "nousresearch", + "alpha", + "oswe", + "neural-chat", + "pangu", + "liquid", + "sourceful", + "allenai", + "palmyra", + "allam", + "canopylabs", + "groq", + "elephant", + ) +) +_COMMON = { + "name": "nonempty", + "description": "nonempty", + "family": _FAMILIES, + **dict.fromkeys( + ("attachment", "reasoning", "tool_call", "structured_output", "temperature", "open_weights"), "boolean" + ), + **dict.fromkeys(("knowledge", "release_date", "last_updated"), "date"), + "modalities": _MODALITIES, + "limit": _LIMIT, +} +_MODEL = ObjectSchema( + { + **_COMMON, + "license": "nonempty", + "links": ArraySchema(_LINK), + "weights": ArraySchema(_WEIGHT), + "benchmarks": ArraySchema(_BENCHMARK), + }, + ("name", "description"), +) +_PROVIDER_MODEL = ObjectSchema( + { + **_COMMON, + "limit": ObjectSchema(_LIMIT.fields, ("context", "output")), + "reasoning_options": ArraySchema("reasoning_option"), + "interleaved": "interleaved", + "cost": _COST, + "status": frozenset({"alpha", "beta", "deprecated"}), + }, + ( + "name", + "description", + "attachment", + "reasoning", + "tool_call", + "release_date", + "last_updated", + "modalities", + "open_weights", + "limit", + ), +) +_PROVIDER = ObjectSchema( + {"name": "nonempty", "env": ArraySchema("string"), "npm": "nonempty", "api": "string", "doc": "nonempty"}, + ("name", "env", "npm", "doc"), +) + + +def validate_source_document(document: SourceDocument, *, effective: bool = False) -> None: + """Validate authored overrides separately from complete inherited results. + + Valid upstream fields outside the selected subset fail explicitly until their + source dispositions are registered. They are never silently ignored. + """ + schema = {"model": _MODEL, "provider": _PROVIDER, "provider_model": _PROVIDER_MODEL}.get(document.kind) + if schema is None: + raise _error(document, "", "SOURCE_MODEL_KIND_INVALID") + partial = document.kind == "provider_model" and "base_model" in document.data and not effective + if partial: + schema = ObjectSchema( + {**schema.fields, "base_model": "nonempty", "base_model_omit": ArraySchema("nonempty")}, ("base_model",) + ) + _validate(document.data, schema, document, "", partial) + if document.kind == "provider": + if not document.data["env"]: + raise _error(document, "env", "SOURCE_CONSTRAINT_INVALID") + npm = document.data["npm"] + api = "api" in document.data + required = {"@ai-sdk/openai-compatible", "@openrouter/ai-sdk-provider", "merge-gateway-ai-sdk-provider"} + optional = {"@ai-sdk/openai", "@ai-sdk/anthropic", "kiro-acp-ai-provider"} + if (npm in required and not api) or (npm not in required | optional and api): + raise _error(document, "api", "SOURCE_CONSTRAINT_INVALID") + if document.kind == "provider_model" and not partial: + reasoning = document.data["reasoning"] + options = "reasoning_options" in document.data + if (reasoning and not options) or (not reasoning and options): + raise _error(document, "reasoning_options", "SOURCE_CONSTRAINT_INVALID") + cost = document.data.get("cost", {}) + if not reasoning and "reasoning" in cost: + raise _error(document, "cost.reasoning", "SOURCE_CONSTRAINT_INVALID") + sizes = [tier["tier"]["size"] for tier in cost.get("tiers", ())] + if len(set(sizes)) != len(sizes): + raise _error(document, "cost.tiers", "SOURCE_CONSTRAINT_INVALID") diff --git a/sdk/nexent/core/model_service/profiles/composition.py b/sdk/nexent/core/model_service/profiles/composition.py new file mode 100644 index 0000000000..1e580b61dc --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/composition.py @@ -0,0 +1,368 @@ +"""Immutable atomic views and exact provider-model policy composition.""" + +import json +from dataclasses import dataclass +from typing import Mapping, Sequence + +from pydantic import ValidationError + +from nexent.core.model_service.profiles._models_dev.reader import _freeze +from nexent.core.model_service.profiles.contracts import ProfileCapabilities, VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import resolve_instruction_mapping +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + apply_parameter_values, + compile_parameter_rules, +) +from nexent.core.model_service.profiles.parameter_schema import ( + ParameterValidationError, + _json_copy, + compile_parameter_schema, + json_values_equal, + validate_parameter_value, +) +from nexent.core.model_service.profiles.resolver import SourceCatalog, SourceCatalogEntry, _revision +from nexent.core.model_service.profiles.supplements import SupplementCatalog + + +COMPOSER_REF = "profile_policy_composer_v4" +_CAPABILITY_PATHS = frozenset( + f"/{group}/{field}" + for group, fields in { + "context": ("combined_window", "input_limit", "output_limit"), + "reasoning": ("status", "controls", "response_paths"), + "tools": ("status", "schema_constraints", "result_format"), + "sampling": ("temperature", "top_p"), + "structured_output": ("status",), + "modalities": ("input", "output"), + "response": ("supported_finish_reasons", "stream_format"), + "usage": ("semantics_id", "input_includes_cached", "output_includes_reasoning"), + }.items() + for field in fields +) + + +class ProfileCompositionError(ValueError): + """A conflict diagnostic never includes parameter or credential values.""" + + def __init__(self, category: str, path: str = ""): + self.category, self.path = category, path + super().__init__(f"{category}: {path}") + + +@dataclass(frozen=True) +class AtomicProfileDefinition: + kind: str + key: str + source: SourceCatalogEntry + capabilities: Mapping[str, object] + parameters: tuple[Mapping[str, object], ...] + bindings: Mapping[str, object] + instruction_reference: VersionedReference | None + model_profile_key: str | None + aliases: tuple[str, ...] + endpoint_hints: tuple[str, ...] + overrides: tuple[Mapping[str, object], ...] + revision: str + + +@dataclass(frozen=True) +class ProfileCatalog: + revision: str + models: Mapping[str, AtomicProfileDefinition] + providers: Mapping[str, AtomicProfileDefinition] + provider_models: Mapping[str, AtomicProfileDefinition] + + +@dataclass(frozen=True) +class ProviderModelPolicy: + """A validated policy draft, not a saved outlet or a transport implementation.""" + + request_model_id: str + model_profile_key: str | None + provider_key: str | None + capabilities: Mapping[str, object] + instruction_mapping: Mapping[str, object] + bindings: Mapping[str, object] + parameter_rules: CompiledParameterRules + parameter_bindings: Mapping[str, Mapping[str, object]] + parameter_values: Mapping[str, object] + overrides: tuple[Mapping[str, object], ...] + catalog_revision: str + revision: str + + +def _check_profile_size(document: Mapping) -> None: + try: + encoded = json.dumps(_json_copy(document), ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode( + "utf-8" + ) + except (ValueError, TypeError, RecursionError, UnicodeError): + raise ProfileCompositionError("PROFILE_JSON_INVALID") from None + if len(encoded) > 1024 * 1024: + raise ProfileCompositionError("PROFILE_SIZE_LIMIT") + + +def _capabilities(value: Mapping) -> dict: + try: + return ProfileCapabilities.model_validate(_json_copy(value)).model_dump(mode="json") + except ValidationError: + raise ProfileCompositionError("PROFILE_CAPABILITIES_INVALID") from None + + +def _apply_override(capabilities: dict, override: Mapping, source: str) -> dict: + path = override["path"] + if path not in _CAPABILITY_PATHS: + raise ProfileCompositionError("PROFILE_OVERRIDE_PATH_INVALID", path) + group, key = path[1:].split("/") + previous = capabilities[group][key] + capabilities[group][key] = _json_copy(override["value"]) + return { + "path": path, + "source": source, + "original_value": previous, + "reason": override["reason"], + "evidence": override["evidence"], + } + + +def _merge_capability_facts(target: dict, facts: Mapping) -> None: + """Overlay authored source facts only; absent fields preserve the base.""" + for key, value in facts.items(): + if isinstance(value, Mapping) and isinstance(target.get(key), dict): + _merge_capability_facts(target[key], value) + else: + target[key] = _json_copy(value) + + +def _compose_declarations(base: Mapping, model: Mapping, provider: Mapping, combination: Mapping, path: str) -> dict: + """Equal-priority atom conflicts require an explicit combination declaration.""" + conflicts = { + key + for key in model.keys() & provider.keys() + if not json_values_equal(model[key], provider[key]) and key not in combination + } + if conflicts: + raise ProfileCompositionError("PROFILE_CONFLICT", path + "/" + min(conflicts)) + result = _json_copy(base) + for declarations in (model, provider, combination): + result.update(_json_copy(declarations)) + return result + + +def build_profile_catalog(sources: SourceCatalog, supplements: SupplementCatalog) -> ProfileCatalog: + """Publish all atomic views together; unsupported bindings are checked at selection.""" + if supplements.source_revision != sources.revision: + raise ProfileCompositionError("PROFILE_SOURCE_REVISION_MISMATCH") + rules = {(entry.rule["target_kind"], entry.rule["target_key"]): entry for entry in supplements.entries.values()} + collections = {"model": {}, "provider": {}, "provider_model": {}} + for kind, entries in ( + ("model", sources.models), + ("provider", sources.providers), + ("provider_model", sources.provider_models), + ): + for key, source in entries.items(): + supplement = rules.get((kind, key)) + rule = supplement.rule if supplement else {} + capabilities = _capabilities(source.capabilities) + overrides, seen = [], {} + for override in rule.get("capability_overrides", ()): + path = override["path"] + if path in seen: + if not json_values_equal(seen[path], override["value"]): + raise ProfileCompositionError("PROFILE_CONFLICT", path) + continue + seen[path] = override["value"] + overrides.append(_apply_override(capabilities, override, supplement.file_path)) + capabilities = _capabilities(capabilities) + reference = rule.get("instruction_mapping") + reference = VersionedReference.model_validate(_json_copy(reference)) if reference else None + parts = { + "composer": COMPOSER_REF, + "source": source.profile_revision, + "supplement_schema": supplements.schema_ref, + } + if supplement: + parts[supplement.file_path] = supplement.content_sha256 + _check_profile_size( + { + "identity": source.identity, + "capabilities": capabilities, + "reference_metadata": source.reference_metadata, + "connection_suggestions": source.connection_suggestions, + "supplement": rule, + } + ) + collections[kind][key] = AtomicProfileDefinition( + kind, + key, + source, + _freeze(capabilities), + _freeze(_json_copy(rule.get("parameters", ()))), + _freeze(_json_copy(rule.get("bindings", {}))), + reference, + rule.get("model_profile_key"), + tuple(rule.get("aliases", ())), + tuple(rule.get("endpoint_hints", ())), + _freeze(overrides), + _revision(parts), + ) + return ProfileCatalog( + _revision({"source": sources.revision, "supplements": supplements.revision, "composer": COMPOSER_REF}), + _freeze(collections["model"]), + _freeze(collections["provider"]), + _freeze(collections["provider_model"]), + ) + + +def compose_provider_model_policy( + catalog: ProfileCatalog, + *, + provider_key: str | None, + request_model_id: str, + model_profile_key: str | None, + base_bindings: Mapping[str, object], + base_instruction: VersionedReference, + registered_references: Mapping[str, Sequence[VersionedReference]], + submitted_values: Mapping[str, object], + protected_api_paths: Sequence[str] = (), + protocol_parameter_rules: Sequence[Mapping[str, object]] = (), +) -> ProviderModelPolicy: + """Compose exact selections against a trusted implementation-availability snapshot.""" + if not isinstance(request_model_id, str) or not request_model_id.strip() or len(request_model_id) > 100: + raise ProfileCompositionError("REQUEST_MODEL_ID_INVALID") + provider = catalog.providers.get(provider_key) if provider_key is not None else None + if provider_key is not None and provider is None: + raise ProfileCompositionError("PROVIDER_PROFILE_NOT_FOUND") + combo = catalog.provider_models.get(f"{provider_key}/{request_model_id}") if provider else None + linked_model = combo.model_profile_key if combo else None + if model_profile_key is not None and linked_model is not None and model_profile_key != linked_model: + raise ProfileCompositionError("PROFILE_CONFLICT", "/selection/model_profile_key") + model_profile_key = model_profile_key if model_profile_key is not None else linked_model + model = catalog.models.get(model_profile_key) if model_profile_key is not None else None + if model_profile_key is not None and model is None: + raise ProfileCompositionError("MODEL_PROFILE_NOT_FOUND") + capabilities = _json_copy(model.capabilities) if model else _capabilities({}) + overrides = list(model.overrides) if model else [] + # Provider rules precede exact hosted-model facts and combination overrides. + for atom in (provider, combo): + if atom is None: + continue + if atom is combo: + _merge_capability_facts(capabilities, atom.source.capabilities) + for override in atom.overrides: + group, field = override["path"][1:].split("/") + overrides.append( + _apply_override( + capabilities, + { + **override, + "value": atom.capabilities[group][field], + }, + override["source"], + ) + ) + capabilities = _capabilities(capabilities) + parameters, conflicts = {}, set() + for atom in (model, provider): + for parameter in atom.parameters if atom else (): + key = parameter["key"] + if key in parameters and not json_values_equal(parameters[key], parameter): + conflicts.add(key) + else: + parameters[key] = _json_copy(parameter) + for parameter in combo.parameters if combo else (): + parameters[parameter["key"]] = _json_copy(parameter) + conflicts.discard(parameter["key"]) + if conflicts: + raise ProfileCompositionError("PROFILE_CONFLICT", "/parameter_rules/" + min(conflicts)) + for rule in protocol_parameter_rules: + declaration = _json_copy(rule) + if rule["key"] in {"temperature", "top_p"}: + status = capabilities["sampling"][rule["key"]]["status"] + if status == "unsupported": + continue + if status == "unknown": + declaration.update(visible=False, allowed_scopes=["outlet"], ui_group="runtime") + parameters.setdefault(rule["key"], declaration) + compiled = compile_parameter_rules(list(parameters.values())) + values = apply_parameter_values(compiled, submitted_values, scope="outlet", saved_values={}) + atomic_bindings = [atom.bindings if atom else {} for atom in (model, provider)] + explicit_bindings = combo.bindings if combo else {} + bindings = _compose_declarations(base_bindings, *atomic_bindings, explicit_bindings, "/bindings") + instruction_declarations = [ + {"strategy": atom.instruction_reference.model_dump(mode="json")} if atom and atom.instruction_reference else {} + for atom in (model, provider, combo) + ] + instruction = VersionedReference.model_validate( + _compose_declarations( + {"strategy": base_instruction.model_dump(mode="json")}, + *instruction_declarations, + "/instruction_mapping", + )["strategy"] + ) + for name in ("protocol", "auth", "adapter", "code_converter", "native_converter"): + if name not in bindings: + raise ProfileCompositionError("PROFILE_BINDING_MISSING", "/bindings/" + name) + try: + reference = VersionedReference.model_validate(bindings[name]) + except ValidationError: + raise ProfileCompositionError("PROFILE_BINDING_INVALID", "/bindings/" + name) from None + if reference not in registered_references.get(name, ()): + raise ProfileCompositionError("PROFILE_BINDING_UNREGISTERED", "/bindings/" + name) + parameter_bindings = validate_parameter_bindings( + compiled, + registered_references.get("parameter_transform", ()), + protected_paths=protected_api_paths, + ) + parameter_schemas = {rule["key"]: compile_parameter_schema(rule["schema"]) for rule in compiled.declarations} + for control in capabilities["reasoning"]["controls"] or (): + if control["parameter_key"] not in parameter_bindings: + raise ProfileCompositionError("REASONING_PARAMETER_BINDING_MISSING", "/capabilities/reasoning/controls") + candidates = list(control.get("values", ())) + list(control.get("special_values", ())) + for bound in ("min", "max"): + if control.get(bound) is not None: + candidates.append(control[bound]) + try: + for candidate in candidates: + validate_parameter_value(parameter_schemas[control["parameter_key"]], candidate) + except ParameterValidationError: + raise ProfileCompositionError( + "REASONING_PARAMETER_SCHEMA_CONFLICT", "/capabilities/reasoning/controls" + ) from None + mapping = resolve_instruction_mapping(instruction).model_dump(mode="json") + _check_profile_size( + { + "request_model_id": request_model_id, + "capabilities": capabilities, + "instruction_mapping": mapping, + "bindings": bindings, + "parameter_rules": compiled.declarations, + "parameter_values": values, + "overrides": overrides, + } + ) + parts = { + "composer": COMPOSER_REF, + "request_model_id": request_model_id, + "bindings": json.dumps(bindings, sort_keys=True), + "instruction": instruction.model_dump_json(), + } + for atom in (model, provider, combo): + if atom: + parts[f"{atom.kind}:{atom.key}"] = atom.revision + return ProviderModelPolicy( + request_model_id, + model_profile_key, + provider_key, + _freeze(capabilities), + _freeze(mapping), + _freeze(bindings), + compiled, + parameter_bindings, + _freeze(values), + _freeze(overrides), + catalog.revision, + _revision(parts), + ) diff --git a/sdk/nexent/core/model_service/profiles/contracts.py b/sdk/nexent/core/model_service/profiles/contracts.py new file mode 100644 index 0000000000..873b5ffad5 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/contracts.py @@ -0,0 +1,236 @@ +"""Strict profile primitives, independent of provider clients and persistence.""" + +import json +from typing import Annotated, Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator + + +SupportStatus = Literal["supported", "unsupported", "unknown"] +NonBlankString = Annotated[str, StringConstraints(pattern=r"\S")] +PositiveTokens = Annotated[int, Field(gt=0)] + + +class ProfileContract(BaseModel): + """Reject undeclared fields and coercion at the profile boundary.""" + + model_config = ConfigDict(strict=True, extra="forbid", frozen=True) + + +class ContextCapabilities(ProfileContract): + """Effective service limits; missing limits remain unknown.""" + + combined_window: PositiveTokens | None = None + input_limit: PositiveTokens | None = None + output_limit: PositiveTokens | None = None + + @model_validator(mode="after") + def validate_limits(self) -> Self: + if self.combined_window is not None: + for limit in (self.input_limit, self.output_limit): + if limit is not None and limit > self.combined_window: + raise ValueError("Service limits cannot exceed the combined context window") + return self + + +class ToggleControl(ProfileContract): + """Explicitly supported thinking switch values, not the current setting.""" + + mode: Literal["toggle"] = "toggle" + parameter_key: Literal["enable_thinking"] = "enable_thinking" + values: list[bool] = Field(min_length=1) + + @model_validator(mode="after") + def validate_values(self) -> Self: + if len(set(self.values)) != len(self.values): + raise ValueError("Toggle values must be unique") + return self + + +class EffortControl(ProfileContract): + """Provider-declared effort values; no universal effort enum is inferred.""" + + mode: Literal["effort"] = "effort" + parameter_key: Literal["reasoning_effort"] = "reasoning_effort" + values: list[NonBlankString | None] = Field(min_length=1) + + @model_validator(mode="after") + def validate_values(self) -> Self: + if len(set(self.values)) != len(self.values): + raise ValueError("Effort values must be unique") + return self + + +class BudgetTokensControl(ProfileContract): + """Known or unknown token bounds and explicitly declared sentinel values.""" + + mode: Literal["budget_tokens"] = "budget_tokens" + parameter_key: Literal["reasoning_budget_tokens"] = "reasoning_budget_tokens" + min: Annotated[int, Field(ge=0)] | None = None + max: Annotated[int, Field(ge=0)] | None = None + special_values: list[int] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError("Budget minimum cannot exceed maximum") + if len(set(self.special_values)) != len(self.special_values): + raise ValueError("Special budget values must be unique") + return self + + +ReasoningControl = Annotated[ToggleControl | EffortControl | BudgetTokensControl, Field(discriminator="mode")] + + +class ReasoningCapabilities(ProfileContract): + """Capability declarations with unknown distinct from unsupported.""" + + status: SupportStatus = "unknown" + controls: list[ReasoningControl] | None = None + response_paths: list[NonBlankString] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_controls(self) -> Self: + if self.status == "unsupported" and (self.controls or self.response_paths): + raise ValueError("Unsupported reasoning cannot declare controls or response paths") + if len(set(self.response_paths)) != len(self.response_paths): + raise ValueError("Reasoning response paths must be unique") + if self.controls is not None: + modes = [control.mode for control in self.controls] + keys = [control.parameter_key for control in self.controls] + if len(set(modes)) != len(modes) or len(set(keys)) != len(keys): + raise ValueError("Reasoning control modes and parameter keys must be unique") + encoded = json.dumps( + [control.model_dump(mode="json") for control in self.controls], + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > 64 * 1024: + raise ValueError("Reasoning controls exceed the 64 KiB UTF-8 limit") + return self + + +class AtomicProfileReference(ProfileContract): + """Provenance only; runtime loading never recomposes from this reference.""" + + key: NonBlankString + catalog_revision: NonBlankString + file_path: NonBlankString + content_sha256: Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{64}$")] + + @model_validator(mode="after") + def validate_source_path(self) -> Self: + parts = self.file_path.split("/") + if ( + "\\" in self.file_path + or ":" in self.file_path + or any(ord(character) < 32 for character in self.file_path) + or any(part in ("", ".", "..") for part in parts) + ): + raise ValueError("Source paths must be canonical relative POSIX paths") + return self + + +class VersionedReference(ProfileContract): + """An explicit registry identity; never a dynamic import path.""" + + ref: NonBlankString + version: Annotated[int, Field(gt=0)] + + +class InstructionRoleMappings(ProfileContract): + """All Nexent semantic instruction/resource categories need explicit targets.""" + + platform_instruction: NonBlankString + application_instruction: NonBlankString + user_input: NonBlankString + assistant_history: NonBlankString + tool_result: NonBlankString + reference_data: NonBlankString + + +class InstructionMappingDefinition(ProfileContract): + """Extensible declaration; execution requires a matching registered strategy.""" + + strategy_id: NonBlankString + strategy_version: Annotated[int, Field(gt=0)] + instruction_hierarchy: list[NonBlankString] + supported_roles: list[NonBlankString] + mappings: InstructionRoleMappings + ordering: list[NonBlankString] + reference_placement: NonBlankString + + @model_validator(mode="after") + def validate_declared_roles(self) -> Self: + for values in (self.instruction_hierarchy, self.supported_roles, self.ordering): + if not values or len(set(values)) != len(values): + raise ValueError("Instruction lists must be nonempty and unique") + if not set(self.instruction_hierarchy) <= set(self.supported_roles): + raise ValueError("Instruction hierarchy must use supported roles") + if not set(self.mappings.model_dump().values()) <= set(self.supported_roles): + raise ValueError("All semantic roles require supported targets") + return self + + +class CapabilityFlag(ProfileContract): + """Missing capability evidence stays unknown.""" + + status: SupportStatus = "unknown" + + +class ToolCapabilities(ProfileContract): + """Native tool support and explicit registered format/constraint contracts.""" + + status: SupportStatus = "unknown" + schema_constraints: VersionedReference | None = None + result_format: NonBlankString | None = None + + +class SamplingCapabilities(ProfileContract): + """Sampling support is independent of configured numeric parameter values.""" + + temperature: CapabilityFlag = Field(default_factory=CapabilityFlag) + top_p: CapabilityFlag = Field(default_factory=CapabilityFlag) + + +class ModalityCapabilities(ProfileContract): + input: list[NonBlankString] | None = None + output: list[NonBlankString] | None = None + + @model_validator(mode="after") + def validate_unique_modalities(self) -> Self: + for values in (self.input, self.output): + if values is not None and len(set(values)) != len(values): + raise ValueError("Modalities must be unique") + return self + + +class ResponseCapabilities(ProfileContract): + supported_finish_reasons: list[NonBlankString] | None = None + stream_format: NonBlankString | None = None + + @model_validator(mode="after") + def validate_finish_reasons(self) -> Self: + if self.supported_finish_reasons is not None: + if len(set(self.supported_finish_reasons)) != len(self.supported_finish_reasons): + raise ValueError("Finish reasons must be unique") + return self + + +class UsageCapabilities(ProfileContract): + semantics_id: NonBlankString | None = None + input_includes_cached: SupportStatus = "unknown" + output_includes_reasoning: SupportStatus = "unknown" + + +class ProfileCapabilities(ProfileContract): + """Strict complete groups; unknown controls never become invented defaults.""" + + context: ContextCapabilities = Field(default_factory=ContextCapabilities) + reasoning: ReasoningCapabilities = Field(default_factory=ReasoningCapabilities) + tools: ToolCapabilities = Field(default_factory=ToolCapabilities) + sampling: SamplingCapabilities = Field(default_factory=SamplingCapabilities) + structured_output: CapabilityFlag = Field(default_factory=CapabilityFlag) + modalities: ModalityCapabilities = Field(default_factory=ModalityCapabilities) + response: ResponseCapabilities = Field(default_factory=ResponseCapabilities) + usage: UsageCapabilities = Field(default_factory=UsageCapabilities) diff --git a/sdk/nexent/core/model_service/profiles/instruction_mapping.py b/sdk/nexent/core/model_service/profiles/instruction_mapping.py new file mode 100644 index 0000000000..ef5c7a88bd --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/instruction_mapping.py @@ -0,0 +1,44 @@ +"""Registered traditional instruction metadata, independent of prompt rendering.""" + +from nexent.core.model_service.profiles.contracts import InstructionMappingDefinition, VersionedReference + + +class InstructionMappingError(ValueError): + """A sanitized registry error without caller-supplied prompt content.""" + + def __init__(self, category: str): + self.category = category + super().__init__(category) + + +def resolve_instruction_mapping(reference: VersionedReference) -> InstructionMappingDefinition: + """Return a detached declaration for the initial traditional role strategy.""" + if reference.ref != "system_only_v1" or reference.version != 1: + raise InstructionMappingError("UNSUPPORTED_INSTRUCTION_MAPPING") + return InstructionMappingDefinition( + strategy_id="system_only", + strategy_version=1, + instruction_hierarchy=["system", "user"], + supported_roles=["system", "user", "assistant", "tool"], + mappings={ + "platform_instruction": "system", + "application_instruction": "system", + "user_input": "user", + "assistant_history": "assistant", + "tool_result": "tool", + "reference_data": "user", + }, + ordering=["platform_instruction", "agent_instruction", "activated_skill_instruction"], + reference_placement="user_data_block", + ) + + +def validate_instruction_mapping(definition: InstructionMappingDefinition) -> VersionedReference: + """Reject declarations that claim unimplemented authority or mapping behavior.""" + reference = VersionedReference( + ref=f"{definition.strategy_id}_v{definition.strategy_version}", version=definition.strategy_version + ) + registered = resolve_instruction_mapping(reference) + if definition.model_dump() != registered.model_dump(): + raise InstructionMappingError("UNSUPPORTED_INSTRUCTION_MAPPING") + return reference diff --git a/sdk/nexent/core/model_service/profiles/manifest.toml b/sdk/nexent/core/model_service/profiles/manifest.toml new file mode 100644 index 0000000000..5ef8ac216d --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/manifest.toml @@ -0,0 +1,229 @@ +schema_version = 1 +source_schema_ref = "models_dev_authored_v1" +upstream_url = "https://github.com/anomalyco/models.dev" +upstream_revision = "e69e1844210e5cfe7c7e7f68d57328bc7451a51c" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.5-27b.toml" +content_sha256 = "15e1c86123b937d3152f69e87650f3b3bbfd9600f8bbfbc5361a6f5e9f69fc16" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.5-35b-a3b.toml" +content_sha256 = "50a139d95791c9c5e145f4a0f115f18c3d38840202c7683e8861f05fe4ffc6be" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.5-397b-a17b.toml" +content_sha256 = "d064d5959f3dca64beb7108a38280877719e89709db7775f932a5ee791e3924e" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.5-flash.toml" +content_sha256 = "39a4329d9772432e58cf92044f143bd7e1445b434ee65830acad958b72322d5c" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.5-plus.toml" +content_sha256 = "15c6b3b7f334219ee6d43c76d1a004e57469e4a4e780e4b254c91474d5cb1bd4" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.8-27b.toml" +content_sha256 = "5a7c7ec1b513c3268756c02fa240f09ba438bce594cbb7d34ebb4f71c4a448e0" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.8-2.4t-a95b.toml" +content_sha256 = "067a17c3a34382a3e286414e8f6ef4ad31844716ed85af41fdab7061ba44add4" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.8-flash.toml" +content_sha256 = "7fc097dbfdcbcf827fc28c17e23bafda6d695ce5d16d96fb65612e4cae581dcc" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3.8-max.toml" +content_sha256 = "ee87c9af3b4188b86dc8ee3d045e9a18a0b611b5db1e47cfc3f88b1672164634" + +[[sources]] +kind = "model" +path = "models/alibaba/qwen3-coder-next.toml" +content_sha256 = "1f4175a40524b382f1e9efac28e4454b477a2f4ccf5a460134b9ed12cb829229" + +[[sources]] +kind = "model" +path = "models/zhipuai/glm-5.1.toml" +content_sha256 = "8c7069a86e0ef764d2a1d0ebb30156abed0cf7b27e5b972c224a5cd9aee27b40" + +[[sources]] +kind = "model" +path = "models/zhipuai/glm-5.2.toml" +content_sha256 = "6ed303f1feeaed5496e919b6c7ae999e2743505a22ed97062b5c833adf37ce13" + +[[sources]] +kind = "model" +path = "models/zhipuai/glm-5.3.toml" +content_sha256 = "8022f2c087c0ad8bdf9f8ed4fe950fda5f7f37ef2a121c577e87fbe28d159c29" + +[[sources]] +kind = "model" +path = "models/zhipuai/glm-5.3-flash.toml" +content_sha256 = "7cdb7a1cfea9ab7e7c3e3392f0fdad0ac9c3f9c92e2f71a2d109788c5d02f843" + +[[sources]] +kind = "model" +path = "models/deepseek/deepseek-v4-flash.toml" +content_sha256 = "02ae90fe9be99047c373f3862afe72367c7a96a7aef7f43864c0bcd9e22da194" + +[[sources]] +kind = "model" +path = "models/deepseek/deepseek-v4-pro.toml" +content_sha256 = "bd08e3df5404ff5f795ac91b44ef34ac7c29a6911de0bd9fded3739501e47996" + +[[sources]] +kind = "model" +path = "models/deepseek/deepseek-v4-flash-0731.toml" +content_sha256 = "cb0133fb9f376da44fc6ee512c8a3ba0399626bb026805f608f265e504511c4e" + +[[sources]] +kind = "model" +path = "models/deepseek/deepseek-v4-pro-0813.toml" +content_sha256 = "e005b286813f8bcf39ecc4397b36a99895532c97bbc2c83f21a94dbaa85455ea" + +[[sources]] +kind = "model" +path = "models/deepseek/deepseek-v4.1-flash.toml" +content_sha256 = "ef594fbed1c96863ff728e31530dbc1987d8862e36c5f1b5f08e5376d631aee0" + +[[sources]] +kind = "model" +path = "models/minimax/MiniMax-M2.7.toml" +content_sha256 = "c63b15b0f281e217f466b973439cfd1876b23d636288cd4f7102ef28a57df761" + +[[sources]] +kind = "model" +path = "models/minimax/MiniMax-M3.toml" +content_sha256 = "a1d815dc34328bd0719fc557794d696205a7506a06515f8e2d84c24f3bce1b1e" + +[[sources]] +kind = "provider" +path = "providers/alibaba-cn/provider.toml" +content_sha256 = "0060172cf93a8db8b8427021030a3ad5c388c919f7fc47df9723ba8b08d5f54b" + +[[sources]] +kind = "provider" +path = "providers/volcengine/provider.toml" +content_sha256 = "b504a2ab81fccb036105dde4a626f486d82e37ceaf348249baaf0b5c8e234377" + +[[sources]] +kind = "provider" +path = "providers/volcengine-coding-plan/provider.toml" +content_sha256 = "15439498a42304ac424f73810fdf8593ee87d19c05bff271c64383afe6b06ee8" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/qwen3.5-397b-a17b.toml" +content_sha256 = "59342059889eac4ab4829384b954db58c72104f9ffc682441d7b39b319b80fb9" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/qwen3.5-flash.toml" +content_sha256 = "48d81041f3babeb002b73184e151df0bb4304b1679137c12e396951bbe9c16fe" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/qwen3.5-plus.toml" +content_sha256 = "5bd06ed609ab5ceeeecf8012a4ea587d5365fcd49c17f10bfb729f1a436fee0e" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/qwen3.8-flash.toml" +content_sha256 = "f496c5f293e2196f69bcd2f705cbb12548f14a1923d6b35d10400cbc7a1c817b" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/qwen3.8-max.toml" +content_sha256 = "1b1b47992952fb615bdce07fdeea93bdf445b36375acae6e5e812e6aecb9bd08" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/glm-5.1.toml" +content_sha256 = "f49b1f19c24db0d339b7bafb8c909c15bdaf5dc3765f9befef66b1166e10c520" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/glm-5.2.toml" +content_sha256 = "dbfbc406418c60aa33f33eed147f8d47a98f544b6f5cb50bf96e20fb5c38a2c2" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/glm-5.3.toml" +content_sha256 = "6ce1f84a6cd1af13f823e846b750e4f39d90aa4f008178091a118f88765dfe2f" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/deepseek-v4-flash.toml" +content_sha256 = "b7f6b5d17332d596883c8d5ac7de1c9e885dc4df8e9fc2ea996420e202a7febb" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/deepseek-v4-pro.toml" +content_sha256 = "db39defdd8ed98cf27ccb03f9884431c1e52f1b5d6a88591888d5801bb1e0ac3" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/deepseek-v4.1-flash.toml" +content_sha256 = "fe4c026f77d2a2b837d390dcef3da521b5dc3298eafb1e06199360cc0c6cd5d0" + +[[sources]] +kind = "provider_model" +path = "providers/alibaba-cn/models/MiniMax/MiniMax-M2.7.toml" +content_sha256 = "d14f335b07a27d36e697d350d2aac28d6f9215e6ad4c2912ff9fe8bd5ac7c2f4" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine/models/deepseek-v4-flash-ga-260731.toml" +content_sha256 = "8012bc8a96ff1a96a56b64721e5e144d4ff997b70985c34483b95c7469de13c1" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine/models/deepseek-v4-pro-ga-260813.toml" +content_sha256 = "444e6d130d11e3132d34e3afe4d878f96120beef51db5946f206d96bc8dfdec8" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine/models/glm-5-2-260617.toml" +content_sha256 = "092fcf694c283b90a71cde982d4231d63e4a53544665d4a481e306b4cb1bed1a" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine/models/glm-5-3-flash-260828.toml" +content_sha256 = "34b35e1742e663e54d1231baa5282fc1497b255d60764df21e018c8a78da04a2" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine-coding-plan/models/deepseek-v4-flash.toml" +content_sha256 = "be24a803ea861218c4ad9fc60025a424cb275b95e7c02507699776ce37869a99" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine-coding-plan/models/deepseek-v4-pro.toml" +content_sha256 = "b359870df413e10509083d9b56a62453c530e0f600ca5628ae3279043df2ee57" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine-coding-plan/models/glm-5.3.toml" +content_sha256 = "6fb6ebfb16a106cfb22e5f887326d661b96c1b67df4f826fdf6d02cceded4782" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine-coding-plan/models/glm-5.3-flash.toml" +content_sha256 = "8ca9644166cb0cdc479049a0f7467a6cebded93cd0d3bef00a66e462b77111f3" + +[[sources]] +kind = "provider_model" +path = "providers/volcengine-coding-plan/models/minimax-m3.toml" +content_sha256 = "c2998f64873231e71f62be2da7983afd4c1af76e7a129f232007e65a58c24e24" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3-coder-next.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3-coder-next.toml new file mode 100644 index 0000000000..cce2c7bb62 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3-coder-next.toml @@ -0,0 +1,27 @@ +# https://qwen.ai/blog?id=qwen3-coder-next +# https://huggingface.co/Qwen/Qwen3-Coder-Next +# https://www.qwencloud.com/models/qwen3-coder-next +name = "Qwen3 Coder Next" +description = "Open-weight Qwen coding model for agents, repository edits, and multi-turn tool use" +family = "qwen" +release_date = "2026-02-03" +last_updated = "2026-02-03" +attachment = false +reasoning = false +temperature = true +tool_call = true +structured_output = true +knowledge = "2025-09" +open_weights = true + +[limit] +context = 262_144 +output = 65_536 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3-Coder-Next" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-27b.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-27b.toml new file mode 100644 index 0000000000..93ad247dd8 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-27b.toml @@ -0,0 +1,29 @@ +name = "Qwen3.5 27B" +description = "Qwen vision-language model for visual reasoning, documents, and agent tasks" +family = "qwen" +release_date = "2026-02-23" +last_updated = "2026-02-23" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 262_144 +output = 65_536 + +[modalities] +input = ["text", "image", "video", "audio"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3.5-27B" + +[[benchmarks]] +name = "SWE-Bench Verified" +score = 72.4 +metric = "resolved" +source = "https://huggingface.co/Qwen/Qwen3.5-27B" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-35b-a3b.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-35b-a3b.toml new file mode 100644 index 0000000000..36700f959e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-35b-a3b.toml @@ -0,0 +1,23 @@ +name = "Qwen3.5 35B-A3B" +description = "Qwen vision-language model for visual reasoning, documents, and agent tasks" +family = "qwen" +release_date = "2026-02-23" +last_updated = "2026-02-23" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 262_144 +output = 65_536 + +[modalities] +input = ["text", "image", "video", "audio"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3.5-35B-A3B" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-397b-a17b.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-397b-a17b.toml new file mode 100644 index 0000000000..103227c16b --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-397b-a17b.toml @@ -0,0 +1,29 @@ +name = "Qwen3.5 397B-A17B" +description = "Large open Qwen multimodal MoE for visual agents and long technical tasks" +family = "qwen" +release_date = "2026-02-15" +last_updated = "2026-02-15" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 262_144 +output = 65_536 + +[modalities] +input = ["text", "image", "video", "audio"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3.5-397B-A17B" + +[[benchmarks]] +name = "SWE-Bench Verified" +score = 76.4 +metric = "resolved" +source = "https://huggingface.co/Qwen/Qwen3.5-397B-A17B" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-flash.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-flash.toml new file mode 100644 index 0000000000..800ff85f43 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-flash.toml @@ -0,0 +1,22 @@ +# https://help.aliyun.com/en/model-studio/qwen3-5-flash +# https://www.alibabacloud.com/help/en/model-studio/deep-thinking +name = "Qwen3.5 Flash" +description = "Qwen vision-language model for visual reasoning, documents, and agent tasks" +family = "qwen" +release_date = "2026-02-23" +last_updated = "2026-02-23" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = false + + +[limit] +context = 1_000_000 +output = 65_536 + +[modalities] +input = ["text", "image", "video"] +output = ["text"] diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-plus.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-plus.toml new file mode 100644 index 0000000000..7f04f4c2fe --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.5-plus.toml @@ -0,0 +1,19 @@ +name = "Qwen3.5 Plus" +description = "Qwen vision-language model for visual reasoning, documents, and agent tasks" +family = "qwen" +release_date = "2026-02-16" +last_updated = "2026-02-16" +attachment = false +reasoning = true +temperature = true +tool_call = true +knowledge = "2025-04" +open_weights = false + +[limit] +context = 1_000_000 +output = 65_536 + +[modalities] +input = ["text", "image", "video"] +output = ["text"] diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-2.4t-a95b.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-2.4t-a95b.toml new file mode 100644 index 0000000000..b30a03ea6b --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-2.4t-a95b.toml @@ -0,0 +1,33 @@ +# Sources (accessed 2026-08-16): +# https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B +# https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B/raw/main/README.md +# https://qwen.ai/blog?id=qwen3.8 +# https://openrouter.ai/qwen/qwen3.8-2.4t-a95b +# Open-weight twin of Qwen3.8 Max: text-only, thinking always on, +# reasoning_effort low|medium|xhigh (default xhigh). Native context 262K, +# extensible to ~1.01M. Distinct from closed multimodal qwen3.8-max. + +name = "Qwen3.8 2.4T A95B" +description = "Open-weight sparse MoE (2.4T total, 95B active), the open-weight twin of Qwen3.8 Max for coding, research, complex reasoning, and agentic workflows" +family = "qwen" +release_date = "2026-08-12" +last_updated = "2026-08-12" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true +license = "qwen3.8-max" + +[limit] +context = 262_144 +output = 131_072 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-27b.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-27b.toml new file mode 100644 index 0000000000..4517f55223 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-27b.toml @@ -0,0 +1,236 @@ +# Benchmark caveats and evaluation harnesses: https://huggingface.co/Qwen/Qwen3.8-27B#evaluation +# Sources (accessed 2026-08-15): +# https://huggingface.co/Qwen/Qwen3.8-27B +# https://huggingface.co/api/models/Qwen/Qwen3.8-27B +# https://qwen.ai/blog?id=qwen3.8 +# Hub lastModified 2026-08-14T15:00:01Z is the open-weight drop. +# Do not use Hub createdAt 2026-08-05 (staged countdown page). + +name = "Qwen3.8 27B" +description = "Dense 27B vision-language model for coding, agent tasks, and image and video understanding" +family = "qwen" +release_date = "2026-08-14" +last_updated = "2026-08-14" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 262_144 +output = 32_768 + +[modalities] +input = ["text", "image", "video"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "SWE-bench Pro" +score = 61.7 +metric = "resolved" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "Terminal-Bench" +score = 73 +metric = "score" +version = "2.1" +harness = "Terminus" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "NL2Repo" +score = 42.3 +metric = "score" +harness = "Claude Code" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "DeepSWE" +score = 42.2 +metric = "score" +version = "1.1" +harness = "Claude Code" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "QwenSWEBench" +score = 79 +metric = "avg@3" +harness = "Claude Code" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "CoWorkBench" +score = 70.7 +metric = "score" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "JobBench" +score = 33.4 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 20.4 +metric = "pass@1" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 42.9 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "IFBench" +score = 79.5 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "GPQA Diamond" +score = 89.2 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 30.8 +metric = "score" +variant = "without tools; GPT-4o judge" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "LiveCodeBench" +score = 90.3 +metric = "score" +version = "6" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "OSWorld-Verified" +score = 84.3 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "WebArena-Verified" +score = 64.8 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" +harness = "OSWorld scaffold; official WebArena-Verified grader" + +[[benchmarks]] +name = "AndroidWorld" +score = 81.9 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "SWE-MM" +score = 38.6 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "RealWorldQA" +score = 85.9 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "ERQA" +score = 65.5 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "RecreationBench" +score = 47.1 +metric = "score" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "ClawEval-MM" +score = 57.4 +metric = "pass@3" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "ClawEval-MM" +score = 56.9 +metric = "average score" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "Vision2Web" +score = 62.9 +metric = "score" +harness = "Claude Code" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" +variant = "gpt-5.4-2026-03-05 judge" + +[[benchmarks]] +name = "MathVision" +score = 90 +metric = "score" +variant = "without code interpreter" +dataset = "corrected annotations" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "MathVision" +score = 94.6 +metric = "score" +variant = "with code interpreter" +dataset = "corrected annotations" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "BabyVision" +score = 65.7 +metric = "score" +variant = "without code interpreter" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "BabyVision" +score = 85.6 +metric = "score" +variant = "with code interpreter" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" + +[[benchmarks]] +name = "CharXiv Reasoning" +score = 83.7 +metric = "score" +variant = "without code interpreter" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" +dataset = "corrected annotations" + +[[benchmarks]] +name = "CharXiv Reasoning" +score = 90.2 +metric = "score" +variant = "with code interpreter" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" +dataset = "corrected annotations" + +[[benchmarks]] +name = "OmniDocBench" +score = 91.1 +metric = "score" +version = "1.5" +source = "https://huggingface.co/Qwen/Qwen3.8-27B" diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-flash.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-flash.toml new file mode 100644 index 0000000000..2ee8a5ff53 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-flash.toml @@ -0,0 +1,19 @@ +# Source: https://www.qwencloud.com/models/qwen3.8-flash +name = "Qwen3.8 Flash" +description = "Qwen vision-language model for visual reasoning, documents, and agent tasks" +family = "qwen" +release_date = "2026-08-26" +last_updated = "2026-08-26" +attachment = true +reasoning = true +tool_call = true +structured_output = true +open_weights = false + +[limit] +context = 1_000_000 +output = 131_072 + +[modalities] +input = ["text", "image", "video"] +output = ["text"] diff --git a/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-max.toml b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-max.toml new file mode 100644 index 0000000000..76a956fd3f --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/alibaba/qwen3.8-max.toml @@ -0,0 +1,266 @@ +# These are the Qwen3.8-Max column of the official model card, not a claim about a separately evaluated local checkpoint. +# Sources (accessed 2026-08-06): +# https://www.qwencloud.com/models/qwen3.8-max +# https://www.qianwenai.com/models/qwen3.8-max +# https://help.aliyun.com/zh/model-studio/qwen3-8-max +# https://www.alibabacloud.com/help/en/model-studio/qwen3-8-max +# https://help.aliyun.com/zh/model-studio/pdf-understanding +# https://platform.qianwenai.com/docs/developer-guides/tool-calling/pdf-understanding +# https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview +# https://help.aliyun.com/zh/model-studio/token-plan-personal-overview +# https://help.aliyun.com/en/model-studio/token-plan-personal-overview +# https://docs.qwencloud.com/developer-guides/getting-started/text-generation-models +# https://docs.qwencloud.com/developer-guides/text-generation/thinking +# https://docs.qwencloud.com/developer-guides/clients-and-developer-tools/opencode +# https://platform.qianwenai.com/docs/developer-guides/clients-and-developer-tools/opencode +# https://qwen.ai/blog?id=qwen3.8 +# PDF input: Model Studio / 千问AI docs list only qwen3.8-max under PDF理解 +# (type:file / file_url|file_data). Model pages list Image/Text/Video badges +# and separately list PDF理解 as a Completions built-in tool. Beijing-region +# availability note on help.aliyun.com; lab capability still includes pdf. + +name = "Qwen3.8 Max" +description = "2.4-trillion-parameter MoE flagship for coding, professional work, multimodal understanding, and long-horizon agentic workflows" +family = "qwen" +release_date = "2026-08-03" +last_updated = "2026-08-03" +attachment = true +reasoning = true +temperature = true +tool_call = true +open_weights = false + +[limit] +context = 1_000_000 +output = 131_072 + +[modalities] +input = ["text", "image", "video", "pdf"] +output = ["text"] + +[[benchmarks]] +name = "Terminal-Bench" +score = 86.6 +metric = "avg@10" +version = "2.1" +harness = "Claude Code" +variant = "5h timeout" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 67.7 +metric = "resolved" +harness = "Claude Code" +dataset = "Qwen refined and corrected task set" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "DeepSWE" +score = 56.6 +metric = "score" +version = "1.1" +harness = "Claude Code" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "NL2Repo" +score = 55.9 +metric = "score" +harness = "Claude Code" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "FrontierSWE" +score = 73.5 +metric = "dominance score" +date = "2026-08-03" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "MLS-Bench-Lite" +score = 41 +metric = "score" +harness = "Claude Code" +variant = "5h timeout" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "PaperBench" +score = 93 +metric = "score" +harness = "BasicAgent" +variant = "Code-Dev; 3 runs; 12h timeout; Opus 4.6 judge" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "AndroidBench" +score = 75.1 +metric = "avg@3" +dataset = "95 public tasks" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "QwenSWEBench" +score = 80.7 +metric = "avg@3" +harness = "Claude Code" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "QwenQoderBench" +score = 58.4 +metric = "avg@5" +harness = "Claude Code" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "QwenReactBench" +score = 1724 +metric = "Elo" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "QwenSVGBench" +score = 1713 +metric = "Elo" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "CoWorkBench" +score = 74.8 +metric = "score" +dataset = "internal" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "WorkSpaceBench" +score = 67.7 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "JobBench" +score = 53.4 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "SkillsBench" +score = 70.2 +metric = "avg@3" +version = "1.1" +dataset = "87 tasks" +harness = "OpenCode" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 27 +metric = "pass@1" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 52.4 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "AutomationBench" +score = 27.3 +metric = "pass@1" +dataset = "600 public tasks" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "Toolathlon-Verified" +score = 72.5 +metric = "pass@1" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "WideSearch" +score = 81.9 +metric = "average item F1" +harness = "Qwen-Agent" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 56.2 +metric = "score" +variant = "with tools" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 43.6 +metric = "score" +variant = "without tools" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "GPQA Diamond" +score = 92.6 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "IFBench" +score = 82.8 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "$OneMillion-Bench" +score = 52.5 +metric = "expert score" +variant = "gemini-3.1-pro-preview judge" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "HealthBench" +score = 60.2 +metric = "score" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "PLawBench" +score = 73.2 +metric = "score" +variant = "gemini-3.1-pro-preview judge" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "PRBench" +score = 57.6 +metric = "score" +variant = "legal" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "PRBench" +score = 58.3 +metric = "score" +variant = "finance" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "MRCR" +score = 92.9 +metric = "score" +version = "2" +dataset = "256K; 8-needle" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" + +[[benchmarks]] +name = "LongBench" +score = 66.3 +metric = "score" +version = "2" +source = "https://huggingface.co/Qwen/Qwen3.8-2.4T-A95B" diff --git a/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash-0731.toml b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash-0731.toml new file mode 100644 index 0000000000..61479ce86a --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash-0731.toml @@ -0,0 +1,105 @@ +name = "DeepSeek V4 Flash 0731" +description = "Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding" +family = "deepseek-flash" +release_date = "2026-07-31" +last_updated = "2026-07-31" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +knowledge = "2025-05" +open_weights = true +license = "MIT" + +[limit] +context = 1_000_000 +output = 384_000 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731" + +[[benchmarks]] +name = "Terminal-Bench" +score = 82.7 +metric = "pass@1" +variant = "max" +version = "2.1" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731" + +[[benchmarks]] +name = "NL2Repo" +score = 54.2 +metric = "resolve rate" +variant = "max effort" +harness = "DeepSeek Harness minimal mode" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "CyberGym" +score = 76.7 +metric = "score" +variant = "max effort" +harness = "DeepSeek Harness minimal mode" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "DeepSWE" +score = 54.4 +metric = "resolve rate" +variant = "max effort" +harness = "DeepSeek Harness minimal mode" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "Toolathlon-Verified" +score = 70.3 +metric = "score" +variant = "max effort" +harness = "DeepSeek Harness minimal mode" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 25.2 +metric = "score" +variant = "max effort" +harness = "DeepSeek Harness minimal mode" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "AutomationBench" +score = 25.1 +metric = "success rate" +variant = "max effort" +dataset = "public" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "DSBench-FullStack" +score = 68.7 +metric = "score" +variant = "max effort" +dataset = "internal" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" + +[[benchmarks]] +name = "DSBench-Hard" +score = 59.6 +metric = "score" +variant = "max effort" +dataset = "internal" +source = "https://api-docs.deepseek.com/updates/" +date = "2026-07-31" diff --git a/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash.toml b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash.toml new file mode 100644 index 0000000000..5f521d77d2 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-flash.toml @@ -0,0 +1,183 @@ +# Added benchmarks are from the original V4 preview instruct-model comparison, not the later 0731/0813 releases. +name = "DeepSeek V4 Flash" +description = "Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work" +family = "deepseek-flash" +release_date = "2026-04-24" +last_updated = "2026-04-24" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +knowledge = "2025-05" +open_weights = true + +[limit] +context = 1_000_000 +output = 384_000 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "SWE-Bench Verified" +score = 79 +metric = "resolved" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "MMLU-Pro" +score = 86.2 +metric = "EM" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "SimpleQA-Verified" +score = 34.1 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "Chinese SimpleQA" +score = 78.9 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "GPQA Diamond" +score = 88.1 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 34.8 +metric = "pass@1" +variant = "preview checkpoint; max effort; without tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "LiveCodeBench" +score = 91.6 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "Codeforces" +score = 3052 +metric = "rating" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "HMMT" +score = 94.8 +metric = "pass@1" +variant = "preview checkpoint; max effort" +dataset = "February 2026" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "IMOAnswerBench" +score = 88.4 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "MathArena Apex" +score = 33 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "MathArena Apex Shortlist" +score = 85.7 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "MRCR" +score = 78.7 +metric = "MMR" +variant = "preview checkpoint; max effort" +dataset = "1M context" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "CorpusQA" +score = 60.5 +metric = "accuracy" +variant = "preview checkpoint; max effort" +dataset = "1M context" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 56.9 +metric = "accuracy" +variant = "preview checkpoint; max effort" +version = "2.0" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 52.6 +metric = "resolved" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "SWE-Bench Multilingual" +score = 73.3 +metric = "resolved" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "BrowseComp" +score = 73.2 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 45.1 +metric = "pass@1" +variant = "preview checkpoint; max effort; with tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "MCP Atlas" +score = 69 +metric = "pass@1" +variant = "preview checkpoint; max effort" +dataset = "public" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "GDPval-AA" +score = 1395 +metric = "Elo" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" + +[[benchmarks]] +name = "Toolathlon" +score = 47.8 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash" diff --git a/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro-0813.toml b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro-0813.toml new file mode 100644 index 0000000000..dade234dbb --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro-0813.toml @@ -0,0 +1,106 @@ +# Public code-agent benchmarks use DeepSeek Harness minimal mode, max effort, temperature=1.0, top_p=0.95. +# https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813 +name = "DeepSeek V4 Pro 0813" +description = "DeepSeek V4 Pro snapshot with million-token context and support for thinking and non-thinking modes" +family = "deepseek-thinking" +release_date = "2026-08-12" +last_updated = "2026-08-22" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true +license = "MIT" + +[limit] +context = 1_000_000 +output = 384_000 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 42.7 +metric = "score" +variant = "without tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 60 +metric = "score" +variant = "with tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "Terminal-Bench" +score = 87.9 +metric = "score" +harness = "DeepSeek Harness minimal mode" +variant = "max effort" +version = "2.1" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "NL2Repo" +score = 61.5 +metric = "score" +harness = "DeepSeek Harness minimal mode" +variant = "max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "CyberGym" +score = 83.3 +metric = "score" +harness = "DeepSeek Harness minimal mode" +variant = "max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "DeepSWE" +score = 62.7 +metric = "score" +harness = "DeepSeek Harness minimal mode" +variant = "max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "Toolathlon-Verified" +score = 74.1 +metric = "score" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 25.7 +metric = "score" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "AutomationBench" +score = 31.8 +metric = "score" +dataset = "public" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "DSBench-FullStack" +score = 71.1 +metric = "score" +dataset = "internal" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" + +[[benchmarks]] +name = "DSBench-Hard" +score = 67.2 +metric = "score" +dataset = "internal" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro-0813" diff --git a/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro.toml b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro.toml new file mode 100644 index 0000000000..c0bb4099ca --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4-pro.toml @@ -0,0 +1,217 @@ +# Added benchmarks are from the original V4 preview instruct-model comparison, not the later 0731/0813 releases. +name = "DeepSeek V4 Pro" +description = "Open MoE flagship with million-token context for coding and long agent runs" +family = "deepseek-thinking" +release_date = "2026-04-24" +last_updated = "2026-04-24" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +knowledge = "2025-05" +open_weights = true + +[limit] +context = 1_000_000 +output = 384_000 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "SWE-Bench Verified" +score = 80.6 +metric = "resolved" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Artificial Analysis Coding Agent Index" +score = 50.1 +metric = "average pass@1" +harness = "Claude Code" +variant = "high" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "SWE-Atlas Codebase QnA" +score = 67.8 +metric = "pass@1" +harness = "Claude Code" +variant = "high" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 18 +metric = "pass@1" +harness = "Claude Code" +variant = "high" +dataset = "hard-aa" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "Terminal-Bench" +score = 64.7 +metric = "pass@1" +harness = "Claude Code" +variant = "high" +version = "2.1" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "MMLU-Pro" +score = 87.5 +metric = "EM" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "SimpleQA-Verified" +score = 57.9 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Chinese SimpleQA" +score = 84.4 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "GPQA Diamond" +score = 90.1 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 37.7 +metric = "pass@1" +variant = "preview checkpoint; max effort; without tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "LiveCodeBench" +score = 93.5 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Codeforces" +score = 3206 +metric = "rating" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "HMMT" +score = 95.2 +metric = "pass@1" +variant = "preview checkpoint; max effort" +dataset = "February 2026" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "IMOAnswerBench" +score = 89.8 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "MathArena Apex" +score = 38.3 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "MathArena Apex Shortlist" +score = 90.2 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "MRCR" +score = 83.5 +metric = "MMR" +variant = "preview checkpoint; max effort" +dataset = "1M context" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "CorpusQA" +score = 62 +metric = "accuracy" +variant = "preview checkpoint; max effort" +dataset = "1M context" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Terminal-Bench" +score = 67.9 +metric = "accuracy" +variant = "preview checkpoint; max effort" +version = "2.0" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 55.4 +metric = "resolved" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "SWE-Bench Multilingual" +score = 76.2 +metric = "resolved" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "BrowseComp" +score = 83.4 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 48.2 +metric = "pass@1" +variant = "preview checkpoint; max effort; with tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "MCP Atlas" +score = 73.6 +metric = "pass@1" +variant = "preview checkpoint; max effort" +dataset = "public" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "GDPval-AA" +score = 1554 +metric = "Elo" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" + +[[benchmarks]] +name = "Toolathlon" +score = 51.8 +metric = "pass@1" +variant = "preview checkpoint; max effort" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro" diff --git a/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4.1-flash.toml b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4.1-flash.toml new file mode 100644 index 0000000000..16a1174cf7 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/deepseek/deepseek-v4.1-flash.toml @@ -0,0 +1,311 @@ +# Benchmarks use instruct weights, temperature=1.0, top_p=0.95; not the base-model table. +# Source: https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash#instruct-model +# Open weights (MIT): https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash +name = "DeepSeek V4.1 Flash" +description = "DeepSeek V4.1 Flash model for reasoning and agentic coding" +family = "deepseek-flash" +release_date = "2026-09-10" +last_updated = "2026-09-10" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +knowledge = "2025-05" +open_weights = true +license = "MIT" + +[limit] +context = 1_000_000 +output = 384_000 + +[modalities] +input = ["text", "image"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "GPQA Diamond" +score = 90.9 +metric = "pass@1" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "MathArena Apex" +score = 65.6 +metric = "pass@1" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 36.8 +metric = "pass@1" +variant = "reasoning_effort=100" +dataset = "full set" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 39.1 +metric = "pass@1" +variant = "reasoning_effort=100" +dataset = "text-only subset" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Codeforces" +score = 3471 +metric = "rating" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 90.6 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "DeepSeek Harness minimal mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 30.0 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "3.0" +harness = "DeepSeek Harness minimal mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 31.2 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "4.0" +harness = "DeepSeek Harness minimal mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 74.2 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "mini-swe-agent" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "ProgramBench" +score = 20.3 +metric = "almost@1" +variant = "reasoning_effort=100" +harness = "DeepSeek Harness minimal mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "NL2Repo" +score = 64.0 +metric = "score" +variant = "reasoning_effort=100" +harness = "DeepSeek Harness minimal mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "CyberGym" +score = 88.1 +metric = "pass@1" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "ExploitGym" +score = 15.3 +metric = "pass@1" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "AutomationBench" +score = 54.8 +metric = "pass@1" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 31.8 +metric = "pass@1" +variant = "reasoning_effort=100" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "SEC-Bench Pro" +score = 62.8 +metric = "pass@1" +variant = "reasoning_effort=100" +harness = "Claude Code" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 63.9 +metric = "pass@1" +variant = "reasoning_effort=100; with tools" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Chartography" +score = 78.9 +metric = "pass@1" +variant = "reasoning_effort=100; with tools" +harness = "Claude Code" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "BabyVision" +score = 89.6 +metric = "pass@1" +variant = "reasoning_effort=100; with tools" +harness = "Claude Code" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "ZeroBench" +score = 49.0 +metric = "pass@5" +variant = "reasoning_effort=100; with tools" +harness = "Claude Code" +dataset = "main" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 69.8 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "Claude Code" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 88.0 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "Claude Code" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 65.6 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "Codex" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 84.1 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "Codex" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 65.5 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "OpenCode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 85.0 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "OpenCode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 66.2 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "Pi" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 86.1 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "Pi" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 90.3 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "mini-swe-agent" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 72.6 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "DeepSeek Harness minimal mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 70.5 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "DeepSeek Harness standard mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 85.8 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "DeepSeek Harness standard mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 67.6 +metric = "resolved" +variant = "reasoning_effort=100" +version = "1.1" +harness = "DeepSeek Harness PTC mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" + +[[benchmarks]] +name = "Terminal-Bench" +score = 85.8 +metric = "pass@1" +variant = "reasoning_effort=100" +version = "2.1" +harness = "DeepSeek Harness PTC mode" +source = "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash" diff --git a/sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M2.7.toml b/sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M2.7.toml new file mode 100644 index 0000000000..e72eaabdce --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M2.7.toml @@ -0,0 +1,46 @@ +name = "MiniMax-M2.7" +description = "Open MiniMax flagship for coding agents, office automation, and complex environments" +family = "minimax" +release_date = "2026-03-18" +last_updated = "2026-03-18" +attachment = false +reasoning = true +temperature = true +tool_call = true +open_weights = true + +[limit] +context = 204_800 +output = 131_072 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/MiniMaxAI/MiniMax-M2.7" + +[[benchmarks]] +name = "SWE-Bench Verified" +score = 79.9 +metric = "resolved" +harness = "Claude Code" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 56.2 +metric = "resolve rate" +harness = "Claude Code" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "Terminal-Bench" +score = 51.1 +metric = "success rate" +version = "2.1" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" diff --git a/sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M3.toml b/sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M3.toml new file mode 100644 index 0000000000..31ad8eeb00 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/minimax/MiniMax-M3.toml @@ -0,0 +1,67 @@ +name = "MiniMax-M3" +description = "MiniMax multimodal model for long-context coding, perception, and agent planning" +family = "minimax" +release_date = "2026-06-01" +last_updated = "2026-06-01" +attachment = true +reasoning = true +temperature = true +tool_call = true +open_weights = true + +[limit] +context = 1_048_576 +output = 512_000 + +[modalities] +input = ["text", "image", "video"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/MiniMaxAI/MiniMax-M3" + +[[benchmarks]] +name = "SWE-Bench Verified" +score = 80.5 +metric = "resolved" +harness = "Claude Code" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 59.0 +metric = "resolve rate" +harness = "Claude Code" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "Terminal-Bench" +score = 66.0 +metric = "success rate" +version = "2.1" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "BrowseComp" +score = 83.52 +metric = "accuracy" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "MCP Atlas" +score = 74.2 +metric = "success rate" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" + +[[benchmarks]] +name = "OSWorld-Verified" +score = 70.06 +metric = "success rate" +source = "https://www.minimax.io/blog/minimax-m3" +date = "2026-06-01" diff --git a/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.1.toml b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.1.toml new file mode 100644 index 0000000000..61860d5017 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.1.toml @@ -0,0 +1,53 @@ +name = "GLM-5.1" +description = "Strong GLM coding model for agentic engineering, terminals, and repository generation" +family = "glm" +release_date = "2026-04-07" +last_updated = "2026-04-07" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 200_000 +output = 131_072 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/zai-org/GLM-5.1" + +[[benchmarks]] +name = "Artificial Analysis Coding Agent Index" +score = 52.7 +metric = "average pass@1" +harness = "Claude Code" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "SWE-Atlas Codebase QnA" +score = 73.2 +metric = "pass@1" +harness = "Claude Code" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 19.8 +metric = "pass@1" +harness = "Claude Code" +dataset = "hard-aa" +source = "https://artificialanalysis.ai/agents/coding-agents" + +[[benchmarks]] +name = "Terminal-Bench" +score = 65.1 +metric = "pass@1" +harness = "Claude Code" +version = "2.1" +source = "https://artificialanalysis.ai/agents/coding-agents" diff --git a/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.2.toml b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.2.toml new file mode 100644 index 0000000000..292a8dff6d --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.2.toml @@ -0,0 +1,167 @@ +name = "GLM-5.2" +description = "Open flagship GLM for long-horizon coding agents and million-token context work" +family = "glm" +release_date = "2026-06-13" +last_updated = "2026-06-13" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 1_000_000 +output = 131_072 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/zai-org/GLM-5.2" + +[[benchmarks]] +name = "SWE-Bench Pro" +score = 62.1 +metric = "resolve rate" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "Terminal-Bench" +score = 82.7 +metric = "success rate" +harness = "Claude Code" +version = "2.1" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "FrontierSWE" +score = 74.4 +metric = "dominance" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 40.5 +metric = "accuracy" +dataset = "text-only subset" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 54.7 +metric = "accuracy" +variant = "with tools" +dataset = "text-only subset" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "CritPt" +score = 20.9 +metric = "accuracy" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "AIME" +score = 99.2 +metric = "accuracy" +version = "2026" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "HMMT" +score = 94.4 +metric = "accuracy" +version = "November 2025" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "HMMT" +score = 92.5 +metric = "accuracy" +version = "February 2026" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "IMOAnswerBench" +score = 91.0 +metric = "accuracy" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "GPQA Diamond" +score = 91.2 +metric = "accuracy" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "NL2Repo" +score = 48.9 +metric = "resolve rate" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "DeepSWE" +score = 46.2 +metric = "resolve rate" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "Program Bench" +score = 63.7 +metric = "score" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "Terminal-Bench" +score = 81.0 +metric = "success rate" +harness = "Terminus 2" +version = "2.1" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "PostTrainBench" +score = 34.3 +metric = "score" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "SWE Marathon" +score = 13.0 +metric = "resolve rate" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "MCP Atlas" +score = 76.8 +metric = "score" +dataset = "public subset" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" + +[[benchmarks]] +name = "Tool-Decathlon" +score = 48.2 +metric = "score" +source = "https://z.ai/blog/glm-5.2" +date = "2026-06-16" diff --git a/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3-flash.toml b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3-flash.toml new file mode 100644 index 0000000000..6f713eea56 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3-flash.toml @@ -0,0 +1,70 @@ +# Scores: https://raw.githubusercontent.com/zai-org/GLM-5/refs/heads/main/resources/bench_53.png +# Open weights: https://huggingface.co/zai-org/GLM-5.3-Flash (MIT, safetensors) +name = "GLM-5.3-Flash" +description = "Native multimodal GLM model for efficient coding and long-horizon agent tasks" +family = "glm-flash" +release_date = "2026-08-26" +last_updated = "2026-08-26" +attachment = true +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 1_000_000 +output = 131_072 + +[modalities] +input = ["text", "image", "video", "pdf"] +output = ["text"] + +[[benchmarks]] +name = "Terminal-Bench" +score = 84.3 +metric = "pass@1" +variant = "max effort" +version = "2.1" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3-Flash" + +[[benchmarks]] +name = "DeepSWE" +score = 63.4 +metric = "resolved" +variant = "max effort" +version = "1.1" +harness = "mini-swe-agent" +source = "https://huggingface.co/zai-org/GLM-5.3-Flash" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 26.3 +metric = "score" +variant = "max effort" +source = "https://huggingface.co/zai-org/GLM-5.3-Flash" + +[[benchmarks]] +name = "AutomationBench" +score = 48.8 +metric = "pass@1" +variant = "max effort" +version = "1.0.6" +source = "https://huggingface.co/zai-org/GLM-5.3-Flash" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 55.3 +metric = "accuracy" +variant = "max effort; with tools" +dataset = "full set" +source = "https://huggingface.co/zai-org/GLM-5.3-Flash" + +[[benchmarks]] +name = "GDPval-AA" +score = 1773 +metric = "Elo" +variant = "max effort" +version = "2" +source = "https://huggingface.co/zai-org/GLM-5.3-Flash" diff --git a/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3.toml b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3.toml new file mode 100644 index 0000000000..e8c11c17ae --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/models/zhipuai/glm-5.3.toml @@ -0,0 +1,158 @@ +# Benchmark settings and caveats: https://huggingface.co/zai-org/GLM-5.3#footnotes +name = "GLM-5.3" +description = "Flagship GLM model for long-horizon coding, agents, and complex project delivery" +family = "glm" +release_date = "2026-08-14" +last_updated = "2026-08-14" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = true +open_weights = true + +[limit] +context = 1_000_000 +output = 131_072 + +[modalities] +input = ["text"] +output = ["text"] + +[[benchmarks]] +name = "Terminal-Bench" +score = 88.2 +metric = "pass@1" +variant = "max effort" +version = "2.1" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "Terminal-Bench" +score = 28.3 +metric = "avg@3" +variant = "max effort" +version = "3.0" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "DeepSWE" +score = 66.9 +metric = "resolved" +variant = "max effort; 6h timeout; 400K context" +version = "1.1" +source = "https://huggingface.co/zai-org/GLM-5.3" +harness = "mini-swe-agent" + +[[benchmarks]] +name = "NL2Repo" +score = 58.0 +metric = "score" +variant = "max effort" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "ProgramBench" +score = 19.0 +metric = "almost solved" +variant = "max effort" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "FrontierSWE" +score = 78.1 +metric = "dominance score" +variant = "max effort" +date = "2026-08-14" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "SWE-Marathon" +score = 42.5 +metric = "score" +variant = "max effort; modified anti-cheat checks" +version = "1.1" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "PostTrainBench" +score = 39.8 +metric = "weighted average" +variant = "max effort; 3 runs; modified external-API checks" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "CyberGym" +score = 84.5 +metric = "pass@1" +variant = "max effort" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "ExploitGym" +score = 105 +metric = "tasks solved" +variant = "max effort; 2h rescaled timeout" +dataset = "869 tasks" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "ExploitGym" +score = 130 +metric = "tasks solved" +variant = "max effort; 6h rescaled timeout" +dataset = "869 tasks" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "ExploitBench" +score = 54.4 +metric = "average coverage" +variant = "max effort; union over 3 revisions" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "Toolathlon-Verified" +score = 73 +metric = "pass@1" +variant = "max effort" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "AutomationBench" +score = 48.2 +metric = "pass@1" +variant = "max effort" +version = "1.0.6" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "Agents' Last Exam" +score = 28.5 +metric = "score" +variant = "max effort; CLI" +harness = "Claude Code 2.1.207" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "Humanity's Last Exam" +score = 62.5 +metric = "accuracy" +variant = "max effort; with tools" +source = "https://huggingface.co/zai-org/GLM-5.3" + +[[benchmarks]] +name = "GDPval-AA" +score = 1769 +metric = "Elo" +variant = "max effort" +version = "2" +source = "https://huggingface.co/zai-org/GLM-5.3" diff --git a/sdk/nexent/core/model_service/profiles/outlet_capabilities.py b/sdk/nexent/core/model_service/profiles/outlet_capabilities.py new file mode 100644 index 0000000000..9ebfde240b --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_capabilities.py @@ -0,0 +1,140 @@ +"""Version-one capability partition for the existing model-record row.""" + +import json +from dataclasses import dataclass +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.contracts import ProfileCapabilities +from nexent.core.model_service.profiles.parameter_schema import _json_copy + + +_CONTEXT_COLUMNS = { + "combined_window": "context_window_tokens", + "input_limit": "max_input_tokens", + "output_limit": "max_output_tokens", +} +_STATUS_COLUMNS = { + "reasoning": "reasoning_supported", + "tools": "native_tool_call_supported", + "structured_output": "structured_output_supported", + "temperature": "temperature_supported", + "top_p": "top_p_supported", +} +CAPABILITY_COLUMN_KEYS = frozenset( + { + *_CONTEXT_COLUMNS.values(), + *_STATUS_COLUMNS.values(), + "reasoning_controls", + "input_modalities", + "output_modalities", + } +) + + +class CapabilityStorageError(ValueError): + """A fixed diagnostic without arbitrary stored values or Pydantic inputs.""" + + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +@dataclass +class StoredOutletCapabilities: + """Detached columns and remaining details; neither contains duplicate values.""" + + columns: dict[str, object] + capability_details: dict[str, object] + + +def _clone(value: object): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise CapabilityStorageError() + return copied + except (ValueError, TypeError, RecursionError, UnicodeError): + raise CapabilityStorageError() from None + + +def _validate(value: object) -> dict: + try: + result = ProfileCapabilities.model_validate(value).model_dump(mode="json") + for number in result["context"].values(): + if number is not None and number >= 2**31: + raise CapabilityStorageError() + return result + except (ValidationError, ValueError, TypeError, UnicodeError): + raise CapabilityStorageError() from None + + +def _status(value: object) -> str: + if value is None: + return "unknown" + if type(value) is not bool: + raise CapabilityStorageError() + return "supported" if value else "unsupported" + + +def split_outlet_capabilities(capabilities: ProfileCapabilities) -> StoredOutletCapabilities: + """Partition a validated complete capability object using fixed field ownership.""" + if not isinstance(capabilities, ProfileCapabilities): + raise CapabilityStorageError() + value = _validate(_clone(capabilities.model_dump(mode="json"))) + columns = {column: value["context"][field] for field, column in _CONTEXT_COLUMNS.items()} + for group, column in _STATUS_COLUMNS.items(): + status = value["sampling"][group]["status"] if group in {"temperature", "top_p"} else value[group]["status"] + columns[column] = {"supported": True, "unsupported": False, "unknown": None}[status] + columns["reasoning_controls"] = value["reasoning"]["controls"] + columns["input_modalities"] = value["modalities"]["input"] + columns["output_modalities"] = value["modalities"]["output"] + details = { + "reasoning": {"response_paths": value["reasoning"]["response_paths"]}, + "tools": {key: value["tools"][key] for key in ("schema_constraints", "result_format")}, + "response": value["response"], + "usage": value["usage"], + } + return StoredOutletCapabilities(columns, details) + + +def restore_outlet_capabilities( + *, + columns: Mapping[str, object], + capability_details: Mapping[str, object], +) -> ProfileCapabilities: + """Restore strictly from row-owned fields; no catalog or default composition.""" + columns, details = _clone(columns), _clone(capability_details) + if not isinstance(columns, dict) or set(columns) != CAPABILITY_COLUMN_KEYS: + raise CapabilityStorageError() + if not isinstance(details, dict) or set(details) != {"reasoning", "tools", "response", "usage"}: + raise CapabilityStorageError() + expected = { + "reasoning": {"response_paths"}, + "tools": {"schema_constraints", "result_format"}, + "response": {"supported_finish_reasons", "stream_format"}, + "usage": {"semantics_id", "input_includes_cached", "output_includes_reasoning"}, + } + for group, keys in expected.items(): + if not isinstance(details[group], dict) or set(details[group]) != keys: + raise CapabilityStorageError() + value = { + "context": {key: columns[column] for key, column in _CONTEXT_COLUMNS.items()}, + "reasoning": { + "status": _status(columns["reasoning_supported"]), + "controls": columns["reasoning_controls"], + **details["reasoning"], + }, + "tools": {"status": _status(columns["native_tool_call_supported"]), **details["tools"]}, + "structured_output": {"status": _status(columns["structured_output_supported"])}, + "sampling": { + key: {"status": _status(columns[column])} + for key, column in _STATUS_COLUMNS.items() + if key in {"temperature", "top_p"} + }, + "modalities": {"input": columns["input_modalities"], "output": columns["output_modalities"]}, + "response": details["response"], + "usage": details["usage"], + } + return ProfileCapabilities.model_validate(_validate(_clone(value))) diff --git a/sdk/nexent/core/model_service/profiles/outlet_parameters.py b/sdk/nexent/core/model_service/profiles/outlet_parameters.py new file mode 100644 index 0000000000..39444135b7 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_parameters.py @@ -0,0 +1,100 @@ +"""Single-location parameter storage, independent of database sessions and TOML.""" + +import math +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +DEDICATED_PARAMETER_KEYS = frozenset( + {"temperature", "top_p", "enable_thinking", "reasoning_effort", "reasoning_budget_tokens", "max_output_tokens"} +) +PARAMETER_COLUMNS = {key: "default_max_output_tokens" if key == "max_output_tokens" else key + for key in DEDICATED_PARAMETER_KEYS} + + +@dataclass +class StoredOutletParameters: + """Detached fields to embed in a row, with no duplicate parameter values.""" + + columns: dict[str, object] + parameter_values: dict[str, object] + explicit_null_parameters: list[str] + + +def _validate_column(key: str, value: object) -> None: + if value is None: + return + valid = False + if key in {"temperature", "top_p"}: + try: + valid = type(value) in {int, float} and math.isfinite(value) + if valid and type(value) is int: + valid = float(value) == value + except OverflowError: + valid = False + elif key == "enable_thinking": + valid = type(value) is bool + elif key == "reasoning_effort": + valid = isinstance(value, str) and bool(value.strip()) and len(value) <= 30 + elif key == "reasoning_budget_tokens": + valid = type(value) is int and -(2**31) <= value < 2**31 + elif key == "max_output_tokens": + valid = type(value) is int and 0 < value < 2**31 + if not valid: + raise ParameterValidationError("PARAMETER_COLUMN_INVALID", "/" + key) + + +def split_outlet_parameters( + compiled: CompiledParameterRules, + values: Mapping[str, object], +) -> StoredOutletParameters: + """Split already resolved values; applying creation defaults is the caller's job.""" + validated = validate_saved_parameter_values(compiled, values) + columns = {PARAMETER_COLUMNS[key]: None for key in sorted(DEDICATED_PARAMETER_KEYS)} + remaining, nulls = {}, [] + for key, value in validated.items(): + if key in DEDICATED_PARAMETER_KEYS: + _validate_column(key, value) + columns[PARAMETER_COLUMNS[key]] = value + if value is None: + nulls.append(key) + else: + remaining[key] = value + return StoredOutletParameters(columns, remaining, sorted(nulls)) + + +def restore_outlet_parameters( + compiled: CompiledParameterRules, + *, + columns: Mapping[str, object], + parameter_values: Mapping[str, object], + explicit_null_parameters: list[str], +) -> dict: + """Restore exactly the saved key presence; reject corruption instead of repairing it.""" + if not isinstance(columns, Mapping) or set(columns) != set(PARAMETER_COLUMNS.values()): + raise ParameterValidationError("PARAMETER_COLUMNS_INVALID") + if ( + not isinstance(explicit_null_parameters, list) + or any(type(key) is not str for key in explicit_null_parameters) + or len(explicit_null_parameters) != len(set(explicit_null_parameters)) + or not set(explicit_null_parameters) <= DEDICATED_PARAMETER_KEYS + ): + raise ParameterValidationError("PARAMETER_NULL_MARKERS_INVALID") + if not isinstance(parameter_values, Mapping) or DEDICATED_PARAMETER_KEYS & parameter_values.keys(): + raise ParameterValidationError("PARAMETER_STORAGE_DUPLICATE") + values = dict(parameter_values) + nulls = set(explicit_null_parameters) + for key, column in PARAMETER_COLUMNS.items(): + value = columns[column] + _validate_column(key, value) + if key in nulls and value is not None: + raise ParameterValidationError("PARAMETER_NULL_MARKERS_INVALID", "/" + key) + if value is not None or key in nulls: + values[key] = value + return validate_saved_parameter_values(compiled, values) diff --git a/sdk/nexent/core/model_service/profiles/outlet_resolution.py b/sdk/nexent/core/model_service/profiles/outlet_resolution.py new file mode 100644 index 0000000000..bf2b15e168 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_resolution.py @@ -0,0 +1,141 @@ +"""Construct complete outlet snapshots from trusted save-time composition.""" + +from collections.abc import Mapping, Sequence + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.composition import COMPOSER_REF, ProfileCatalog, ProviderModelPolicy +from nexent.core.model_service.profiles.contracts import InstructionMappingDefinition, VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + InstructionMappingError, + validate_instruction_mapping, +) +from nexent.core.model_service.profiles.outlet_storage import ProfileStorageError, encode_resolved_model_profile +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, _json_copy +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, + OutletIdentity, + ResolvedModelProfile, +) +from nexent.core.model_service.profiles.resolver import PARSER_REF +from nexent.core.model_service.profiles.supplements import SUPPLEMENT_SCHEMA_REF + + +class OutletResolutionError(ValueError): + """A construction diagnostic contains categories, never submitted values.""" + + def __init__(self, category): + self.category = category + super().__init__(category) + + +def _selected_atoms(policy, catalog): + if policy.catalog_revision != catalog.revision: + raise OutletResolutionError("PROFILE_CATALOG_REVISION_MISMATCH") + model = catalog.models.get(policy.model_profile_key) if policy.model_profile_key else None + provider = catalog.providers.get(policy.provider_key) if policy.provider_key else None + if (policy.model_profile_key and model is None) or (policy.provider_key and provider is None): + raise OutletResolutionError("PROFILE_SELECTION_INVALID") + combination = catalog.provider_models.get(f"{policy.provider_key}/{policy.request_model_id}") if provider else None + if combination and combination.model_profile_key and combination.model_profile_key != policy.model_profile_key: + raise OutletResolutionError("PROFILE_SELECTION_INVALID") + return {"model": model, "provider": provider, "provider_model": combination} + + +def _provenance(policy, atoms): + references, origins, metadata = {}, [], {} + for kind, atom in atoms.items(): + references[kind] = None + if atom is None: + continue + candidates = [reference for reference in atom.source.source_refs if reference.key == atom.key] + if len(candidates) != 1: + raise OutletResolutionError("PROFILE_SOURCE_REFERENCE_INVALID") + references[kind] = candidates[0].model_dump(mode="json") + for origin in atom.source.field_origins.values(): + mapped = origin.mapping + origins.append({ + "target_path": origin.target_path, + "source_path": mapped.source.file_path + "#" + mapped.source.source_path, + "atom_kind": mapped.source.source_kind, + "rule_id": mapped.rule_id, + "rule_version": mapped.rule_version, + }) + if atom.source.reference_metadata: + metadata[kind] = _json_copy(atom.source.reference_metadata) + return { + "atomic_refs": references, + "interpretation_versions": { + "source_parser": {"ref": PARSER_REF, "version": 1}, + "source_mapping": {"ref": "models_dev_source_v1", "version": 1}, + "supplement_schema": {"ref": SUPPLEMENT_SCHEMA_REF, "version": 1}, + "composer": {"ref": COMPOSER_REF, "version": 4}, + }, + "overrides": _json_copy(policy.overrides), + "field_sources": origins, + "provider_metadata": metadata, + } + + +def construct_resolved_model_profile( + policy: ProviderModelPolicy, + catalog: ProfileCatalog, + *, + tenant_id: str, + display_name: str, + model_type: str, + provider_id: str, + connection: OutletConnection, + connection_rules: ConnectionRules, + context_configuration: ContextConfiguration, + registered_references: Mapping[str, Sequence[VersionedReference]], + extra_api_parameters: Sequence[Mapping] = (), + protected_paths: Sequence[str] = (), +) -> ResolvedModelProfile: + """Build a new outlet from a server-composed draft; never accept a frontend-authored policy. + + No defaults are applied here. The caller provides registry/connection policy + and owns the subsequent database transaction and credential assignment. + """ + if not isinstance(policy, ProviderModelPolicy) or not isinstance(catalog, ProfileCatalog): + raise OutletResolutionError("PROFILE_SELECTION_INVALID") + atoms = _selected_atoms(policy, catalog) + try: + snapshot = ResolvedModelProfile( + schema_version=1, + outlet_id=None, + config_revision=1, + identity=OutletIdentity( + tenant_id=tenant_id, + display_name=display_name, + model_type=model_type, + provider_id=provider_id, + request_model_id=policy.request_model_id, + provider_key=policy.provider_key, + model_profile_key=policy.model_profile_key, + ), + connection=connection.model_dump(mode="json"), + connection_rules=connection_rules.model_dump(mode="json"), + context_configuration=context_configuration.model_dump(mode="json"), + capabilities=_json_copy(policy.capabilities), + instruction_mapping=_json_copy(policy.instruction_mapping), + parameter_rules=_json_copy(policy.parameter_rules.declarations), + parameter_values=_json_copy(policy.parameter_values), + extra_api_parameters=_json_copy(extra_api_parameters), + bindings={ + **_json_copy(policy.bindings), + "role_mapping": validate_instruction_mapping( + InstructionMappingDefinition.model_validate(_json_copy(policy.instruction_mapping)) + ).model_dump(mode="json"), + }, + provenance=_provenance(policy, atoms), + ) + # Reuse the save boundary for availability, column and ownership checks. + encode_resolved_model_profile( + snapshot, registered_references=registered_references, protected_paths=protected_paths + ) + return snapshot + except (ValidationError, ProfileStorageError, InstructionMappingError, ParameterValidationError, TypeError, AttributeError): + raise OutletResolutionError("PROFILE_RESOLUTION_INVALID") from None diff --git a/sdk/nexent/core/model_service/profiles/outlet_rules.py b/sdk/nexent/core/model_service/profiles/outlet_rules.py new file mode 100644 index 0000000000..2a89c7624e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_rules.py @@ -0,0 +1,130 @@ +"""Store rule constraints as explicit references to same-row control domains.""" + +import json +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.contracts import ReasoningCapabilities +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + compile_parameter_rules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import _json_copy, json_values_equal + + +class RuleStorageError(ValueError): + """A fixed storage diagnostic that never includes private constraint values.""" + + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +def _clone(value): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise RuleStorageError() + return copied + except (ValueError, TypeError, RecursionError, UnicodeError): + raise RuleStorageError() from None + + +def _domains(controls): + try: + reasoning = ReasoningCapabilities.model_validate({"controls": _clone(controls)}) + except (ValidationError, ValueError, TypeError): + raise RuleStorageError() from None + domains = {} + for control in reasoning.controls or []: + value = control.model_dump(mode="json") + if control.mode in {"toggle", "effort"}: + keywords = {"enum": value["values"]} + else: + keywords = { + keyword: value[field] + for keyword, field in (("minimum", "min"), ("maximum", "max")) + if value[field] is not None + } + domains[control.parameter_key] = (control.mode, keywords) + return domains + + +def store_parameter_rules( + compiled: CompiledParameterRules, + *, + reasoning_controls: object, + saved_values: Mapping[str, object], +) -> list[dict]: + """Remove only exact duplicate domain/fixed values, preserving extra constraints.""" + try: + saved = validate_saved_parameter_values(compiled, saved_values) + rules = _clone(compiled.declarations) + domains = _domains(reasoning_controls) + for rule in rules: + key = rule["key"] + if key in domains: + mode, domain = domains[key] + keywords = sorted( + keyword + for keyword, value in domain.items() + if keyword in rule["schema"] and json_values_equal(rule["schema"][keyword], value) + ) + if keywords: + rule["value_domain_ref"] = {"mode": mode, "keywords": keywords} + for keyword in keywords: + del rule["schema"][keyword] + if "fixed_value" in rule and key in saved: + rule.pop("fixed_value") + rule["fixed_to_saved_value"] = True + return _clone(rules) + except (ValueError, TypeError, KeyError, AttributeError): + raise RuleStorageError() from None + + +def restore_parameter_rules( + declarations: object, + *, + reasoning_controls: object, + saved_values: Mapping[str, object], +) -> CompiledParameterRules: + """Restore constraints from row-owned domains; never apply creation defaults.""" + try: + rules, saved = _clone(declarations), _clone(saved_values) + if not isinstance(rules, list) or not isinstance(saved, dict): + raise RuleStorageError() + domains = _domains(reasoning_controls) + for rule in rules: + if not isinstance(rule, dict) or not isinstance(rule.get("schema"), dict): + raise RuleStorageError() + key = rule.get("key") + has_reference = "value_domain_ref" in rule + reference = rule.pop("value_domain_ref", None) + if has_reference: + if not isinstance(reference, dict) or set(reference) != {"mode", "keywords"} or key not in domains: + raise RuleStorageError() + mode, domain = domains[key] + keywords = reference["keywords"] + if ( + reference["mode"] != mode + or not isinstance(keywords, list) + or not keywords + or any(type(word) is not str for word in keywords) + or len(set(keywords)) != len(keywords) + or not set(keywords) <= domain.keys() + or set(keywords) & rule["schema"].keys() + ): + raise RuleStorageError() + for keyword in keywords: + rule["schema"][keyword] = domain[keyword] + if "fixed_to_saved_value" in rule: + if rule.pop("fixed_to_saved_value") is not True or "fixed_value" in rule or key not in saved: + raise RuleStorageError() + rule["fixed_value"] = saved[key] + compiled = compile_parameter_rules(_clone(rules)) + validate_saved_parameter_values(compiled, saved) + return compiled + except (ValueError, TypeError, KeyError): + raise RuleStorageError() from None diff --git a/sdk/nexent/core/model_service/profiles/outlet_storage.py b/sdk/nexent/core/model_service/profiles/outlet_storage.py new file mode 100644 index 0000000000..5473e8d04d --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_storage.py @@ -0,0 +1,215 @@ +"""Fixed single-row storage codec; never loads source catalogs or credentials.""" + +from typing import Mapping, Sequence + +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_capabilities import ( + CAPABILITY_COLUMN_KEYS, + restore_outlet_capabilities, + split_outlet_capabilities, +) +from nexent.core.model_service.profiles.outlet_parameters import ( + DEDICATED_PARAMETER_KEYS, + PARAMETER_COLUMNS, + split_outlet_parameters, +) +from nexent.core.model_service.profiles.outlet_rules import restore_parameter_rules, store_parameter_rules +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules +from nexent.core.model_service.profiles.parameter_schema import _json_copy +from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + +_IDENTITY = { + "tenant_id": "tenant_id", + "display_name": "display_name", + "model_type": "model_type", + "provider_id": "model_factory", + "request_model_id": "model_name", +} +_CONNECTION = ("base_url", "ssl_verify", "timeout_seconds", "concurrency_limit") +_CONTEXT = { + "output_reserve_tokens": "default_output_reserve_tokens", + "tokenizer_family": "tokenizer_family", + "capacity_source": "capacity_source", + "capability_profile_version": "capability_profile_version", +} +_REMAINDER = { + "parameter_rules", + "instruction_mapping", + "capability_details", + "bindings", + "provenance", + "connection_rules", + "explicit_null_parameters", +} + + +class ProfileStorageError(ValueError): + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +def _validate_available(profile, registered_references, protected_paths): + for key, reference in profile.bindings: + if key == "role_mapping": + continue # The executable local instruction registry already validates this declaration. + if reference not in registered_references.get(key, ()): + raise ProfileStorageError() + compiled = compile_parameter_rules(profile.parameter_rules) + bindings = validate_parameter_bindings( + compiled, registered_references.get("parameter_transform", ()), protected_paths=protected_paths + ) + apply_extra_api_parameters( + {}, + profile.extra_api_parameters, + parameter_paths=[binding["path"] for binding in bindings.values()], + protected_paths=protected_paths, + ) + return compiled + + +def encode_resolved_model_profile( + profile: ResolvedModelProfile, + *, + registered_references: Mapping[str, Sequence[VersionedReference]], + protected_paths: Sequence[str] = (), +) -> dict: + """Return row-owned fields; credential values and audit fields belong to the caller.""" + try: + profile = ResolvedModelProfile.model_validate(_json_copy(profile.model_dump(mode="json"))) + compiled = _validate_available(profile, registered_references, protected_paths) + capabilities = split_outlet_capabilities(profile.capabilities) + parameters = split_outlet_parameters(compiled, profile.parameter_values) + values = profile.model_dump(mode="json") + bindings = values["bindings"] + protocol = bindings.pop("protocol") + bindings["protocol_version"] = protocol["version"] + remainder = { + "parameter_rules": store_parameter_rules( + compiled, + reasoning_controls=capabilities.columns["reasoning_controls"], + saved_values=profile.parameter_values, + ), + "instruction_mapping": values["instruction_mapping"], + "capability_details": capabilities.capability_details, + "bindings": bindings, + "provenance": values["provenance"], + "connection_rules": values["connection_rules"], + "explicit_null_parameters": parameters.explicit_null_parameters, + } + row = {column: values["identity"][field] for field, column in _IDENTITY.items()} + row.update({key: values["connection"][key] for key in _CONNECTION}) + row.update({column: values["context_configuration"][field] for field, column in _CONTEXT.items()}) + row.update(capabilities.columns) + row.update(parameters.columns) + row.update( + profile_schema_version=profile.schema_version, + config_revision=profile.config_revision, + protocol_id=protocol["ref"], + resolved_profile=remainder, + extra_params={ + "parameter_values": parameters.parameter_values, + "api_parameters": values["extra_api_parameters"], + }, + ) + if profile.outlet_id is not None: + row["model_id"] = profile.outlet_id + return _json_copy(row) + except (ValueError, TypeError, KeyError, AttributeError, UnicodeError): + raise ProfileStorageError() from None + + +def decode_resolved_model_profile( + row: Mapping[str, object], + *, + registered_references: Mapping[str, Sequence[VersionedReference]], + protected_paths: Sequence[str] = (), +) -> ResolvedModelProfile: + """Restore this row only; reject incomplete migration states and unavailable versions.""" + try: + if type(row["model_id"]) is not int or not 0 < row["model_id"] < 2**31: + raise ProfileStorageError() + remainder, extra = _json_copy(row["resolved_profile"]), _json_copy(row["extra_params"]) + if not isinstance(remainder, dict) or set(remainder) != _REMAINDER: + raise ProfileStorageError() + if not isinstance(extra, dict) or set(extra) != {"parameter_values", "api_parameters"}: + raise ProfileStorageError() + capability_columns = {key: row[key] for key in CAPABILITY_COLUMN_KEYS} + capabilities = restore_outlet_capabilities( + columns=capability_columns, capability_details=remainder["capability_details"] + ) + # Recover presence first; the restored declaration will validate schemas and fixed constraints. + saved = _json_copy(extra["parameter_values"]) + nulls = remainder["explicit_null_parameters"] + if ( + not isinstance(saved, dict) + or DEDICATED_PARAMETER_KEYS & saved.keys() + or not isinstance(nulls, list) + or any(type(key) is not str for key in nulls) + or len(nulls) != len(set(nulls)) + or not set(nulls) <= DEDICATED_PARAMETER_KEYS + ): + raise ProfileStorageError() + for key in DEDICATED_PARAMETER_KEYS: + value = row[PARAMETER_COLUMNS[key]] + if key in nulls and value is not None: + raise ProfileStorageError() + if value is not None or key in nulls: + saved[key] = value + for declaration in remainder["parameter_rules"]: + key = declaration.get("key") + if key not in {"temperature", "top_p"}: + continue + declared_type = declaration.get("schema", {}).get("type") + integer_only = declared_type == "integer" or ( + isinstance(declared_type, list) + and "integer" in declared_type + and set(declared_type) <= {"integer", "null"} + ) + value = saved.get(key) + if integer_only and type(value) is float and value.is_integer(): + saved[key] = int(value) + compiled = restore_parameter_rules( + remainder["parameter_rules"], + reasoning_controls=capability_columns["reasoning_controls"], + saved_values=saved, + ) + # The common parameter codec enforces SQL column types, in addition to the restored schemas. + split_outlet_parameters(compiled, saved) + bindings = remainder["bindings"] + if not isinstance(bindings, dict) or "protocol" in bindings or "protocol_version" not in bindings: + raise ProfileStorageError() + bindings["protocol"] = {"ref": row["protocol_id"], "version": bindings.pop("protocol_version")} + connection = {key: row[key] for key in _CONNECTION} + connection["credential_ref"] = { + "kind": remainder["connection_rules"]["credential_kind"], + "model_id": row["model_id"], + } + identity = {key: row[column] for key, column in _IDENTITY.items()} + for field, atom in (("provider_key", "provider"), ("model_profile_key", "model")): + reference = remainder["provenance"]["atomic_refs"][atom] + identity[field] = reference["key"] if reference is not None else None + profile = ResolvedModelProfile.model_validate( + { + "schema_version": row["profile_schema_version"], + "outlet_id": row["model_id"], + "config_revision": row["config_revision"], + "identity": identity, + "connection": connection, + "connection_rules": remainder["connection_rules"], + "context_configuration": {key: row[column] for key, column in _CONTEXT.items()}, + "capabilities": capabilities.model_dump(mode="json"), + "instruction_mapping": remainder["instruction_mapping"], + "parameter_rules": compiled.declarations, + "parameter_values": saved, + "extra_api_parameters": extra["api_parameters"], + "bindings": bindings, + "provenance": remainder["provenance"], + } + ) + _validate_available(profile, registered_references, protected_paths) + return profile + except (ValueError, TypeError, KeyError, AttributeError, UnicodeError): + raise ProfileStorageError() from None diff --git a/sdk/nexent/core/model_service/profiles/parameter_bindings.py b/sdk/nexent/core/model_service/profiles/parameter_bindings.py new file mode 100644 index 0000000000..b5de00b6f1 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_bindings.py @@ -0,0 +1,53 @@ +"""Validate explicit body bindings against registered parameter transforms.""" + +from typing import Mapping, Sequence + +from pydantic import ValidationError + +from nexent.core.model_service.profiles._models_dev.reader import _freeze +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.parameter_rules import CompiledParameterRules +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.request_parameters import _CONTROLLED_PATHS, _overlap, _tokens + + +def validate_parameter_bindings( + rules: CompiledParameterRules, + registered_transforms: Sequence[VersionedReference], + *, + protected_paths: Sequence[str] = (), +) -> Mapping[str, Mapping[str, object]]: + """Return immutable declarations; this does not render or send model requests.""" + protected = [_tokens(path) for path in (*_CONTROLLED_PATHS, *protected_paths)] + paths, bindings = [], {} + for rule in rules.declarations: + location = "/parameter_rules/" + rule["key"].replace("~", "~0").replace("/", "~1") + "/api_binding" + binding = rule.get("api_binding") + if binding is None: + if rule["visible"] or rule["editable"]: + raise ParameterValidationError("PARAMETER_BINDING_MISSING", location) + continue + if not isinstance(binding, dict) or set(binding) != {"path", "transform_ref", "transform_version"}: + raise ParameterValidationError("PARAMETER_BINDING_INVALID", location) + try: + reference = VersionedReference.model_validate( + { + "ref": binding["transform_ref"], + "version": binding["transform_version"], + } + ) + except ValidationError: + raise ParameterValidationError("PARAMETER_BINDING_INVALID", location) from None + if reference not in registered_transforms: + raise ParameterValidationError("PARAMETER_TRANSFORM_UNREGISTERED", location) + try: + tokens = _tokens(binding["path"]) + except ParameterValidationError: + raise ParameterValidationError("PARAMETER_BINDING_INVALID", location) from None + if any(_overlap(tokens, path) for path in protected): + raise ParameterValidationError("RESERVED_API_PATH", location) + if any(_overlap(tokens, path) for path in paths): + raise ParameterValidationError("PARAMETER_BINDING_CONFLICT", location) + paths.append(tokens) + bindings[rule["key"]] = binding + return _freeze(bindings) diff --git a/sdk/nexent/core/model_service/profiles/parameter_rules.py b/sdk/nexent/core/model_service/profiles/parameter_rules.py new file mode 100644 index 0000000000..ac57d2e8c4 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_rules.py @@ -0,0 +1,240 @@ +"""Explicit parameter policy shared by outlet and conversation configuration.""" + +import json +from dataclasses import dataclass, field +from typing import Literal, Mapping + +from pydantic import Field, JsonValue, ValidationError + +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract +from nexent.core.model_service.profiles.parameter_schema import ( + CompiledParameterSchema, + ParameterValidationError, + compile_parameter_schema, + json_values_equal, + validate_parameter_value, +) + + +class ParameterRule(ProfileContract): + """Declaration only; field presence distinguishes missing from explicit null.""" + + key: NonBlankString + value_schema: dict = Field(alias="schema") + visible: bool + editable: bool + required: bool + allowed_scopes: list[Literal["outlet", "conversation"]] + ui_group: NonBlankString + owner: Literal["model", "provider", "combination"] + source: dict[str, JsonValue] + default_value: JsonValue = Field(default=None, repr=False) + fixed_value: JsonValue = Field(default=None, repr=False) + condition: dict | None = None + api_binding: dict[str, JsonValue] | None = None + + +@dataclass(frozen=True) +class CompiledParameterRules: + """Validated immutable declarations, schemas and dependency ordering.""" + + _declarations: bytes = field(repr=False) + _schemas: tuple[tuple[str, CompiledParameterSchema], ...] = field(repr=False) + _order: tuple[str, ...] + + @property + def declarations(self) -> list[dict]: + return json.loads(self._declarations) + + +def _pointer(key: str) -> str: + return "/" + key.replace("~", "~0").replace("/", "~1") + + +def _check_condition(condition: object, keys: set[str], path: str) -> set[str]: + if not isinstance(condition, dict): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + for operator in ("all", "any"): + if operator in condition: + children = condition[operator] + if set(condition) != {operator} or not isinstance(children, list) or not children: + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + dependencies = set() + for index, child in enumerate(children): + dependencies.update(_check_condition(child, keys, f"{path}/{operator}/{index}")) + return dependencies + if ( + set(condition) != {"key", "op", "value"} + or not isinstance(condition.get("key"), str) + or condition["key"] not in keys + ): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + operator, value = condition["op"], condition["value"] + if operator not in ("eq", "in", "exists"): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + if operator == "exists" and type(value) is not bool: + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + if operator == "in" and (not isinstance(value, list) or not value): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + validate_parameter_value(compile_parameter_schema({}), value, path) + return {condition["key"]} + + +def _condition_matches(condition: dict | None, values: dict) -> bool: + if condition is None: + return True + if "all" in condition: + return all(_condition_matches(child, values) for child in condition["all"]) + if "any" in condition: + return any(_condition_matches(child, values) for child in condition["any"]) + key, operator, expected = condition["key"], condition["op"], condition["value"] + if operator == "exists": + return (key in values) is expected + if key not in values: + return False + if operator == "eq": + return json_values_equal(values[key], expected) + return any(json_values_equal(values[key], option) for option in expected) + + +def compile_parameter_rules(declarations: list[dict]) -> CompiledParameterRules: + """Reject ambiguous rules before they can accept user configuration.""" + validate_parameter_value(compile_parameter_schema({"type": "array", "items": {"type": "object"}}), declarations) + encoded = json.dumps(declarations, ensure_ascii=False, allow_nan=False, sort_keys=True).encode("utf-8") + # Work from a detached JSON snapshot, never retain caller-owned containers. + copied = json.loads(encoded) + rules, schemas = {}, {} + for declaration in copied: + try: + rule = ParameterRule.model_validate(declaration) + except ValidationError: + raise ParameterValidationError("PARAMETER_RULE_INVALID") from None + if rule.key in rules or len(set(rule.allowed_scopes)) != len(rule.allowed_scopes): + raise ParameterValidationError("PARAMETER_RULE_INVALID", _pointer(rule.key)) + schema = compile_parameter_schema(rule.value_schema) + for name in ("default_value", "fixed_value"): + if name in rule.model_fields_set: + validate_parameter_value(schema, getattr(rule, name), _pointer(rule.key)) + if {"default_value", "fixed_value"} <= rule.model_fields_set and not json_values_equal( + rule.default_value, rule.fixed_value + ): + raise ParameterValidationError("PARAMETER_RULE_INVALID", _pointer(rule.key)) + rules[rule.key], schemas[rule.key] = rule, schema + dependencies = { + key: _check_condition(rule.condition, set(rules), _pointer(key) + "/condition") + if rule.condition is not None + else set() + for key, rule in rules.items() + } + order = [] + while dependencies: + ready = sorted(key for key, parents in dependencies.items() if not parents) + if not ready: + raise ParameterValidationError("PARAMETER_CONDITION_CYCLE", _pointer(min(dependencies))) + order.extend(ready) + for key in ready: + del dependencies[key] + for parents in dependencies.values(): + parents.difference_update(ready) + return CompiledParameterRules(encoded, tuple(schemas.items()), tuple(order)) + + +def apply_parameter_values( + compiled: CompiledParameterRules, + submitted: Mapping[str, object], + *, + scope: Literal["outlet", "conversation"], + saved_values: Mapping[str, object], +) -> dict: + """Build a detached effective view; never mutate saved values or declarations.""" + if scope not in ("outlet", "conversation"): + raise ParameterValidationError("PARAMETER_SCOPE_INVALID") + object_schema = compile_parameter_schema({"type": "object"}) + validate_parameter_value(object_schema, submitted) + validate_parameter_value(object_schema, saved_values) + submitted = json.loads(json.dumps(dict(submitted))) + saved_values = json.loads(json.dumps(dict(saved_values))) + rules = {rule["key"]: rule for rule in compiled.declarations} + schemas = dict(compiled._schemas) + for key in submitted.keys() | saved_values.keys(): + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + values = {} + for key, rule in rules.items(): + if "fixed_value" in rule: + values[key] = rule["fixed_value"] + if key in saved_values and not json_values_equal(saved_values[key], values[key]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif key in saved_values: + values[key] = saved_values[key] + elif "default_value" in rule: + values[key] = rule["default_value"] + if key in submitted: + if "fixed_value" in rule: + if not json_values_equal(submitted[key], values[key]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif not rule["editable"] or scope not in rule["allowed_scopes"]: + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + values[key] = submitted[key] + if key in values: + validate_parameter_value(schemas[key], values[key], _pointer(key)) + for key in compiled._order: + rule = rules[key] + if not _condition_matches(rule.get("condition"), values): + if key in submitted: + raise ParameterValidationError("PARAMETER_DEPENDENCY", _pointer(key)) + values.pop(key, None) + elif rule["required"] and key not in values: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _pointer(key)) + return values + + +def overlay_saved_parameter_values(compiled, submitted, *, scope, saved_values): + """Apply explicit run choices without creation defaults or silent dependency removal.""" + if scope not in {"outlet", "conversation"}: + raise ParameterValidationError("PARAMETER_SCOPE_INVALID") + validate_parameter_value(compile_parameter_schema({"type": "object"}), submitted) + rules = {rule["key"]: rule for rule in compiled.declarations} + values = dict(saved_values) + for key, value in submitted.items(): + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + rule = rules[key] + if scope == "conversation" and (not rule["editable"] or scope not in rule["allowed_scopes"]): + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + if "fixed_value" in rule: + if not json_values_equal(value, rule["fixed_value"]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif (not rule["editable"] or scope not in rule["allowed_scopes"]) and not ( + key in saved_values and json_values_equal(value, saved_values[key])): + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + values[key] = value + return validate_saved_parameter_values(compiled, values) + + +def validate_saved_parameter_values( + compiled: CompiledParameterRules, + saved_values: Mapping[str, object], +) -> dict: + """Validate a persisted snapshot without applying defaults or changing presence.""" + validate_parameter_value(compile_parameter_schema({"type": "object"}), saved_values) + values = json.loads(json.dumps(dict(saved_values))) + rules = {rule["key"]: rule for rule in compiled.declarations} + schemas = dict(compiled._schemas) + for key in values: + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + validate_parameter_value(schemas[key], values[key], _pointer(key)) + for key in compiled._order: + rule = rules[key] + active = _condition_matches(rule.get("condition"), values) + if not active: + if key in values: + raise ParameterValidationError("PARAMETER_DEPENDENCY", _pointer(key)) + continue + if "fixed_value" in rule: + if key not in values or not json_values_equal(values[key], rule["fixed_value"]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif rule["required"] and key not in values: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _pointer(key)) + return values diff --git a/sdk/nexent/core/model_service/profiles/parameter_schema.py b/sdk/nexent/core/model_service/profiles/parameter_schema.py new file mode 100644 index 0000000000..5ef5433e3d --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_schema.py @@ -0,0 +1,212 @@ +"""Finite parameter schema validation without evaluation or remote resolution.""" + +import json +import math +from dataclasses import dataclass, field +from typing import Mapping + + +_KEYWORDS = frozenset( + { + "type", + "enum", + "const", + "minimum", + "maximum", + "minLength", + "maxLength", + "items", + "properties", + "required", + "additionalProperties", + } +) +_TYPES = frozenset({"null", "boolean", "integer", "number", "string", "array", "object"}) + + +class ParameterValidationError(ValueError): + """A value-free diagnostic suitable for configuration error projection.""" + + def __init__(self, category: str, path: str = "", keyword: str = ""): + self.category = category + self.path = path + self.keyword = keyword + super().__init__(f"{category}: {path}: {keyword}") + + +@dataclass(frozen=True) +class CompiledParameterSchema: + """Canonical immutable schema bytes; callers receive independent copies.""" + + _json: bytes = field(repr=False) + + @property + def schema(self) -> dict: + return json.loads(self._json) + + +def _path(parent: str, key: object) -> str: + return parent + "/" + str(key).replace("~", "~0").replace("/", "~1") + + +def _json_copy(value: object, path: str = "", ancestors: frozenset[int] = frozenset()): + if value is None or type(value) in (bool, int, str): + return value + if type(value) is float: + if math.isfinite(value): + return value + elif isinstance(value, (Mapping, list, tuple)): + if id(value) in ancestors: + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + ancestors = ancestors | {id(value)} + if isinstance(value, Mapping): + copied = {} + for key, item in value.items(): + if not isinstance(key, str): + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + copied[key] = _json_copy(item, _path(path, key), ancestors) + return copied + return [_json_copy(item, _path(path, index), ancestors) for index, item in enumerate(value)] + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + + +def json_values_equal(left: object, right: object) -> bool: + """JSON numeric equality never conflates booleans with numbers.""" + if type(left) in (int, float) and type(right) in (int, float): + return left == right + if isinstance(left, Mapping) and isinstance(right, Mapping): + return left.keys() == right.keys() and all(json_values_equal(value, right[key]) for key, value in left.items()) + if isinstance(left, (list, tuple)) and isinstance(right, (list, tuple)): + return len(left) == len(right) and all(json_values_equal(a, b) for a, b in zip(left, right)) + return type(left) is type(right) and left == right + + +def _check_schema(schema: object, path: str = "") -> None: + if not isinstance(schema, dict): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", path) + for key in schema: + if key not in _KEYWORDS: + raise ParameterValidationError("PARAMETER_SCHEMA_KEYWORD_UNSUPPORTED", _path(path, key), key) + if "type" in schema: + types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if ( + not types + or any(not isinstance(kind, str) or kind not in _TYPES for kind in types) + or len(set(types)) != len(types) + ): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "type"), "type") + if "enum" in schema: + values = schema["enum"] + if ( + not isinstance(values, list) + or not values + or any( + json_values_equal(value, previous) for index, value in enumerate(values) for previous in values[:index] + ) + ): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "enum"), "enum") + for key in ("minimum", "maximum"): + if key in schema and type(schema[key]) not in (int, float): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, key), key) + for key in ("minLength", "maxLength"): + if key in schema and (type(schema[key]) is not int or schema[key] < 0): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, key), key) + for lower, upper in (("minimum", "maximum"), ("minLength", "maxLength")): + if lower in schema and upper in schema and schema[lower] > schema[upper]: + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", path, lower) + if "required" in schema: + keys = schema["required"] + if not isinstance(keys, list) or any(not isinstance(key, str) for key in keys) or len(set(keys)) != len(keys): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "required"), "required") + if "items" in schema: + _check_schema(schema["items"], _path(path, "items")) + if "properties" in schema: + if not isinstance(schema["properties"], dict): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "properties"), "properties") + for key, child in schema["properties"].items(): + _check_schema(child, _path(_path(path, "properties"), key)) + if "additionalProperties" in schema and type(schema["additionalProperties"]) is not bool: + _check_schema(schema["additionalProperties"], _path(path, "additionalProperties")) + + +def compile_parameter_schema(schema: Mapping[str, object]) -> CompiledParameterSchema: + """Reject unsupported syntax before parameter values can use this schema.""" + try: + copied = _json_copy(schema) + _check_schema(copied) + encoded = json.dumps( + copied, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + except (RecursionError, UnicodeError, TypeError, ValueError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID") from None + return CompiledParameterSchema(encoded) + + +def _matches(value: object, kind: str) -> bool: + return { + "null": value is None, + "boolean": type(value) is bool, + "integer": type(value) is int, + "number": type(value) in (int, float), + "string": isinstance(value, str), + "array": isinstance(value, list), + "object": isinstance(value, dict), + }[kind] + + +def _validate(schema: dict, value: object, path: str) -> None: + if "type" in schema: + kinds = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(_matches(value, kind) for kind in kinds): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "type") + if "enum" in schema and not any(json_values_equal(value, option) for option in schema["enum"]): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "enum") + if "const" in schema and not json_values_equal(value, schema["const"]): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "const") + if type(value) in (int, float): + for keyword in ("minimum", "maximum"): + if keyword in schema and ( + (keyword == "minimum" and value < schema[keyword]) + or (keyword == "maximum" and value > schema[keyword]) + ): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, keyword) + if isinstance(value, str): + for keyword in ("minLength", "maxLength"): + if keyword in schema and ( + (keyword == "minLength" and len(value) < schema[keyword]) + or (keyword == "maxLength" and len(value) > schema[keyword]) + ): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, keyword) + if isinstance(value, list) and "items" in schema: + for index, item in enumerate(value): + _validate(schema["items"], item, _path(path, index)) + if isinstance(value, dict): + for key in schema.get("required", ()): + if key not in value: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _path(path, key), "required") + properties = schema.get("properties", {}) + additional = schema.get("additionalProperties", True) + for key, item in value.items(): + if key in properties: + _validate(properties[key], item, _path(path, key)) + elif additional is False: + raise ParameterValidationError( + "PARAMETER_ADDITIONAL_PROPERTY", _path(path, key), "additionalProperties" + ) + elif isinstance(additional, dict): + _validate(additional, item, _path(path, key)) + + +def validate_parameter_value(schema: CompiledParameterSchema, value: object, path: str = "") -> None: + """Validate the submitted JSON value without defaulting or coercion.""" + try: + copied = _json_copy(value, path) + # Validate UTF-8 serializability as well as the Python structure. + json.dumps(copied, ensure_ascii=False, allow_nan=False).encode("utf-8") + _validate(schema.schema, copied, path) + except (RecursionError, UnicodeError, TypeError, ValueError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) from None diff --git a/sdk/nexent/core/model_service/profiles/protocol_parameters.py b/sdk/nexent/core/model_service/profiles/protocol_parameters.py new file mode 100644 index 0000000000..7a314ab2fd --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/protocol_parameters.py @@ -0,0 +1,18 @@ +"""Fallback request declarations for the registered OpenAI chat protocol.""" + + +def openai_chat_parameter_rules(): + """Declare wire fields without assigning provider capability or request defaults.""" + fields = { + "temperature": ({"type": ["number", "null"]}, "/temperature", True), + "top_p": ({"type": ["number", "null"]}, "/top_p", True), + "max_output_tokens": ({"type": "integer", "minimum": 1}, "/max_tokens", True), + "stop": ({"type": ["string", "array"], "items": {"type": "string"}}, "/stop", False), + "response_format": ({"type": "object"}, "/response_format", False), + } + return [{"key": key, "schema": schema, "visible": visible, "editable": True, "required": False, + "allowed_scopes": ["outlet", "conversation"] if visible else ["outlet"], + "ui_group": "advanced" if visible else "runtime", "owner": "provider", + "source": {"ref": "openai_chat_protocol_parameters_v1", "version": 1}, + "api_binding": {"path": path, "transform_ref": "identity_v1", "transform_version": 1}} + for key, (schema, path, visible) in fields.items()] diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/MiniMax/MiniMax-M2.7.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/MiniMax/MiniMax-M2.7.toml new file mode 100644 index 0000000000..80d471831f --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/MiniMax/MiniMax-M2.7.toml @@ -0,0 +1,25 @@ +name = "MiniMax-M2.7" +description = "MiniMax model for chat, coding, office work, and agentic tasks" +family = "minimax" +release_date = "2026-03-18" +last_updated = "2026-03-18" +attachment = false +reasoning = true +temperature = true +tool_call = true +open_weights = true +reasoning_options = [] + +[cost] +input = 0.30 +output = 1.20 +cache_read = 0.06 +cache_write = 0.375 + +[limit] +context = 204_800 +output = 131_072 + +[modalities] +input = ["text"] +output = ["text"] diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-flash.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-flash.toml new file mode 100644 index 0000000000..f37c5ab26f --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-flash.toml @@ -0,0 +1,16 @@ +base_model = "deepseek/deepseek-v4-flash" + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["high", "max"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.14 +output = 0.28 +cache_read = 0.0028 diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-pro.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-pro.toml new file mode 100644 index 0000000000..722857a529 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4-pro.toml @@ -0,0 +1,16 @@ +base_model = "deepseek/deepseek-v4-pro" + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["high", "max"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.435 +output = 0.87 +cache_read = 0.003625 diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4.1-flash.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4.1-flash.toml new file mode 100644 index 0000000000..6fe0ab8159 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/deepseek-v4.1-flash.toml @@ -0,0 +1,27 @@ +# CNY→USD rate: 6.721845, 2026-09-18, source: https://open.er-api.com/v6/latest/USD +# Sources (accessed 2026-09-18): +# https://bailian.console.aliyun.com/cn-beijing?tab=model#/model-market/detail/deepseek-v4.1-flash?serviceSite=asia-pacific-china +# https://help.aliyun.com/zh/model-studio/deepseek-api — capability table, +# reasoning_effort ladder, max_tokens default (393_216, shared with thinking) +# https://help.aliyun.com/zh/model-studio/billing-for-model-studio — Beijing +# peak/off-peak pricing: CNY 2/1 input, 8/4 output per 1M tokens with context +# cache discount; peak price converted to USD using the rate above (off-peak +# is half). +# Hybrid reasoning: enable_thinking = true|false toggles thinking; +# reasoning_effort = low|high|max (default high) — low is only supported on +# v4.1-flash / v4-flash-0731 / v4-pro-0813, and v4.1-flash is the current +# low-tier bearer. reasoning_content streams in deltas. Responses API +# supported (Beijing and Singapore only). Limits and modalities are identical +# to the deepseek lab entry (1M context, 384_000 output, text+image input), +# so they are inherited and not restated. +base_model = "deepseek/deepseek-v4.1-flash" + +reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "high", "max"] }] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.29754 +output = 1.19015 +cache_read = 0.01488 diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.1.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.1.toml new file mode 100644 index 0000000000..e19b35f1e8 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.1.toml @@ -0,0 +1,45 @@ +# Chinese mainland deployment is tiered on input tokens per request; every +# token in a request bills at its tier's unit price. The 0.87/3.48 previously +# published here matches neither band; both bands below are taken from the +# vendor page. No cache-read price is published for either band, so the +# existing curated cache_read is retained on the base and not restated. +# https://www.alibabacloud.com/help/en/model-studio/model-pricing (accessed 2026-08-26) +name = "GLM-5.1" +description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" +family = "glm" +release_date = "2026-04-14" +last_updated = "2026-04-14" +attachment = false +reasoning = true +structured_output = true +temperature = true +tool_call = true +open_weights = true + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "budget_tokens" +max = 131_072 + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.825 +output = 3.301 +cache_read = 0.17 + +[[cost.tiers]] +tier = { type = "context", size = 32_000 } +input = 1.1 +output = 3.851 + +[limit] +context = 202_752 +output = 128_000 + +[modalities] +input = ["text"] +output = ["text"] diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.2.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.2.toml new file mode 100644 index 0000000000..b847c801b2 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.2.toml @@ -0,0 +1,22 @@ +base_model = "zhipuai/glm-5.2" + +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +# First party price citation here +# https://bailian.console.alibabacloud.com/cn-beijing?tab=model#/model-market/detail/glm-5.2?serviceSite=asia-pacific-china +[cost] +input = 1.1 +output = 3.851 +cache_read = 0.275 +cache_write = 0 + +[limit] +context = 1000000 +output = 128_000 + +[modalities] +input = ["text"] +output = ["text"] diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.3.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.3.toml new file mode 100644 index 0000000000..f6d0f6e67e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/glm-5.3.toml @@ -0,0 +1,25 @@ +# GLM-5.3 on Alibaba Bailian (Model Studio, Beijing): always-on thinking with +# reasoning_effort low|high|max (default max). Thinking cannot be turned off +# (enable_thinking only accepts true; passing false errors) and there is no +# thinking_budget — so a single effort option, no toggle, no budget. +# reasoning_content is streamed in deltas. +# Pricing: Beijing CNY 8 / 28 / 2 (cache hit) per 1M tokens, same list price +# as glm-5.2 on the same host (see providers/alibaba-cn/models/glm-5.2.toml). +# Limits and modalities are identical to the zhipuai lab entry +# (1M context, 131_072 output, text-only), so they are inherited and not restated. +# https://bailian.console.aliyun.com/cn-beijing?tab=model#/model-market/detail/glm-5.3?serviceSite=asia-pacific-china +# https://docs.bigmodel.cn/cn/guide/models/text/glm-5.3 +base_model = "zhipuai/glm-5.3" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 1.1 +output = 3.851 +cache_read = 0.275 +cache_write = 0 diff --git a/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.5-397b-a17b.toml b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.5-397b-a17b.toml new file mode 100644 index 0000000000..8bafd8221f --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/alibaba-cn/models/qwen3.5-397b-a17b.toml @@ -0,0 +1,42 @@ +# Chinese mainland deployment is tiered on input tokens per request: +# 0 reasoning_tokens 57, disabled -> 0). +# No graded effort: the same body accepts reasoning_effort, but acceptance is not +# effect. Over n=5 runs per level on a prompt hard enough to consume the budget, +# none and minimal were hard zeros (the off switch) while the graded values fully +# overlapped -- low 659/873/216/487/845, medium 271/1548/556/296/1245, +# high 121/123/132/349/262, xhigh 928/753/268/480/110, max 811/435/570/910/623. +# 'high' sits below 'low', so the levels are not separable and only the toggle is +# declared, matching the toggle the lab (providers/deepseek) exposes. +base_model = "deepseek/deepseek-v4-flash" + +[[reasoning_options]] +type = "toggle" + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0 +output = 0 +cache_read = 0 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/deepseek-v4-pro.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/deepseek-v4-pro.toml new file mode 100644 index 0000000000..68e7561f92 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/deepseek-v4-pro.toml @@ -0,0 +1,22 @@ +# Coding Plan model name from https://www.volcengine.com/docs/82379/1928261 +# (accessed 2026-08-26). Subscription tier: no per-token price. +# Toggle: thinking.type = enabled|disabled, POST /api/coding/v3/chat/completions +# (verified: enabled -> reasoning_tokens 79, disabled -> 0). +# No graded effort: the same body accepts reasoning_effort, but acceptance is not +# effect. Over n=5 runs per level on a prompt hard enough to consume the budget, +# none and minimal were hard zeros (the off switch) while the graded values fully +# overlapped -- low 200/517/616/638/313, high 466/673/604/1075/415, +# max 389/204/240/1535/688. The levels are not separable, so only the toggle is +# declared, matching the toggle the lab (providers/deepseek) exposes. +base_model = "deepseek/deepseek-v4-pro" + +[[reasoning_options]] +type = "toggle" + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0 +output = 0 +cache_read = 0 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3-flash.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3-flash.toml new file mode 100644 index 0000000000..45dd0b497e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3-flash.toml @@ -0,0 +1,17 @@ +# Coding Plan model name from https://www.volcengine.com/docs/82379/1928261 +# (accessed 2026-09-11): the model list includes both glm-5.3 (glm-latest) +# and glm-5.3-flash. Subscription tier: no per-token price. +# Effort levels mirror providers/zai-coding-plan/glm-5.3-flash.toml. +base_model = "zhipuai/glm-5.3-flash" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0 +output = 0 +cache_read = 0 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3.toml new file mode 100644 index 0000000000..8d75263dbe --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/glm-5.3.toml @@ -0,0 +1,30 @@ +# Coding Plan model name from https://www.volcengine.com/docs/82379/1928261 +# (accessed 2026-08-26). Subscription tier: no per-token price. +# Effort: POST /api/coding/v3/chat/completions accepts low|medium|high|xhigh|max, +# but only the three effective levels are declared. This model reports +# reasoning_tokens = 0 on every level, so the measure is reasoning_content length; +# over n=5 runs per level on a prompt hard enough to consume the thinking budget: +# low 0 0 0 0 0 medium 0 0 0 0 0 -> medium aliases low +# high 169 149 0 188 0 +# xhigh 2840 1943 3338 4655 6279 max 2363 4567 2962 4606 2327 +# -> xhigh aliases max +# Three distinct tiers, matching the lab (providers/zai) and the coding-plan peers. +# Volcengine's mapping table (82379/1449737) covers glm-5-2-260617 but not 5.3, and +# that row disagrees with what 5.3 does here (it maps low/medium to high, whereas +# 5.3 returns no reasoning at all on both), so these levels are set from the +# measurements above rather than from that table. +# No toggle: both reasoning_effort=none|minimal and thinking.type=disabled are +# rejected with 'not supported by this model'. +base_model = "zhipuai/glm-5.3" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0 +output = 0 +cache_read = 0 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/minimax-m3.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/minimax-m3.toml new file mode 100644 index 0000000000..60ca6e93c0 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/models/minimax-m3.toml @@ -0,0 +1,21 @@ +# Coding Plan model name from https://www.volcengine.com/docs/82379/1928261 +# (accessed 2026-08-26). Subscription tier: no per-token price. +# Toggle: thinking.type = enabled|disabled, POST /api/coding/v3/chat/completions +# Toggle only, matching first-party MiniMax and minimax-coding-plan. +# The same body also accepts reasoning_effort, but acceptance is not effect: +# over n=3 runs per level on a hard arithmetic prompt, reasoning_content length was +# minimal 1161/1033/805, high 1195/741/840, max 957/709/1088 -- fully overlapping, +# so no graded control is declared. 'none' is likewise accepted without disabling +# thinking (reasoning_tokens stays non-zero), so thinking.type is the real switch. +base_model = "minimax/MiniMax-M3" + +[[reasoning_options]] +type = "toggle" + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0 +output = 0 +cache_read = 0 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/provider.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/provider.toml new file mode 100644 index 0000000000..7420f6ed56 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine-coding-plan/provider.toml @@ -0,0 +1,42 @@ +name = "Volcengine Ark Coding Plan" +env = ["ARK_CODING_PLAN_API_KEY"] +npm = "@ai-sdk/openai-compatible" +# Coding Plan is a subscription tier with its own base URL. Volcengine warns that +# calling the pay-as-you-go https://ark.cn-beijing.volces.com/api/v3 with a Coding +# Plan key does NOT draw down the subscription and bills separately, so the two +# providers must stay distinct. +# The env var is deliberately not ARK_API_KEY: /api/coding/v3 does not check which +# plan a key belongs to, so a pay-as-you-go key reaches it and bills per token. +# Sharing ARK_API_KEY would light up both providers at once and let a user pick a +# Coding Plan entry while silently paying pay-as-you-go rates. +# Reasoning HTTP format (accessed 2026-08-26): +# OpenAI Chat: POST /api/coding/v3/chat/completions; `thinking.type` = enabled or +# disabled (`auto` is rejected on most models); `reasoning_effort` is accepted on +# every model but only some values have effect, so each file declares just the +# levels it honours, measured on this endpoint (n=4-6 per level, 2026-08-27): +# doubao-seed-2.1-turbo / 2.0-lite / evolving -> none|low|high +# (medium overlaps low, xhigh and max overlap high rather than exceeding it) +# glm-5.3 -> low|high|max +# (rejects none and minimal outright; medium aliases low, xhigh aliases max) +# deepseek-v4-flash / -pro -> toggle only +# (graded values fully overlap; on Flash 'high' lands below 'low') +# kimi-k2.7-code -> no controls +# minimax-m3 -> toggle only +# (tolerates none but still spends reasoning tokens on it, so it is not an +# off switch there; thinking.type is) +# Plaintext reasoning is in `choices[].message.reasoning_content` +# and the encrypted block in `choices[].message.encrypted_content`. +# Anthropic: base https://ark.cn-beijing.volces.com/api/coding, POST /v1/messages +# with `thinking.type` and `output_config.effort`. +# Model names here are the short console labels (doubao-seed-2.1-turbo). This +# endpoint is the more permissive of the two: it accepts the dated IDs as well +# (verified 2026-08-27 - doubao-seed-2-1-turbo-260628, doubao-seed-2-0-lite-260428 +# and glm-5-3-260930 all resolve here), whereas the pay-as-you-go /api/v3 accepts +# only the dated form and 404s on a short label. The short labels are used because +# they are what the Coding Plan console shows. `ark-code-latest` follows whichever +# model is selected in the console. +# Sources: +# https://www.volcengine.com/docs/82379/1928261 +# https://www.volcengine.com/docs/82379/2546386 +doc = "https://www.volcengine.com/docs/82379/1928261" +api = "https://ark.cn-beijing.volces.com/api/coding/v3" diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-flash-ga-260731.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-flash-ga-260731.toml new file mode 100644 index 0000000000..85e6279908 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-flash-ga-260731.toml @@ -0,0 +1,21 @@ +# Model ID verified against POST /api/v3/chat/completions (2026-08-27): `deepseek-v4-flash-ga-260731` +# CNY→USD rate: 6.737012, 2026-08-26, source: https://open.er-api.com/v6/latest/USD +# CNY list price source: https://www.volcengine.com/docs/82379/1544106 +# Toggle: thinking.type = enabled|disabled +# Effort: reasoning_effort = minimal|low|medium|high +base_model = "deepseek/deepseek-v4-flash-0731" + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.4453 +output = 1.3359 +cache_read = 0.01484 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-pro-ga-260813.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-pro-ga-260813.toml new file mode 100644 index 0000000000..369ff4ef40 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/deepseek-v4-pro-ga-260813.toml @@ -0,0 +1,21 @@ +# Model ID verified against POST /api/v3/chat/completions (2026-08-27): `deepseek-v4-pro-ga-260813` +# CNY→USD rate: 6.737012, 2026-08-26, source: https://open.er-api.com/v6/latest/USD +# CNY list price source: https://www.volcengine.com/docs/82379/1544106 +# Toggle: thinking.type = enabled|disabled +# Effort: reasoning_effort = minimal|low|medium|high +base_model = "deepseek/deepseek-v4-pro-0813" + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 1.3359 +output = 4.00771 +cache_read = 0.04453 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-2-260617.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-2-260617.toml new file mode 100644 index 0000000000..64005aefe7 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-2-260617.toml @@ -0,0 +1,21 @@ +# Model ID verified against POST /api/v3/chat/completions (2026-08-27): `glm-5-2-260617` +# CNY→USD rate: 6.737012, 2026-08-26, source: https://open.er-api.com/v6/latest/USD +# CNY list price source: https://www.volcengine.com/docs/82379/1544106 +# Toggle: thinking.type = enabled|disabled +# Effort: reasoning_effort = minimal|low|medium|high +base_model = "zhipuai/glm-5.2" + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 1.18747 +output = 4.15615 +cache_read = 0.29687 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-3-flash-260828.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-3-flash-260828.toml new file mode 100644 index 0000000000..98cb87ee9b --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine/models/glm-5-3-flash-260828.toml @@ -0,0 +1,18 @@ +# Model ID verified against POST /api/v3/chat/completions (2026-09-16): `glm-5-3-flash-260828` +# CNY→USD rate: 6.737012, 2026-08-26, source: https://open.er-api.com/v6/latest/USD +# CNY list price source: https://docs.volcengine.com/docs/82379/1449737 (深度思考) + 智谱官方价目表 +# GLM-5.3-Flash always reasons (thinking cannot be disabled on this model ID) +# Effort: reasoning_effort = low|high|max +base_model = "zhipuai/glm-5.3-flash" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.11875 +output = 0.41563 +cache_read = 0.03414 diff --git a/sdk/nexent/core/model_service/profiles/providers/volcengine/provider.toml b/sdk/nexent/core/model_service/profiles/providers/volcengine/provider.toml new file mode 100644 index 0000000000..6ca805b6ab --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/providers/volcengine/provider.toml @@ -0,0 +1,23 @@ +name = "Volcengine Ark" +env = ["ARK_API_KEY"] +npm = "@ai-sdk/openai-compatible" +# Reasoning HTTP format (accessed 2026-08-26): +# OpenAI Chat: POST /api/v3/chat/completions; `thinking.type` = enabled|disabled +# (`auto` is documented but rejected with "Unsupported thinking type for the +# current model" on every model here except doubao-seed-1-6-flash), +# `reasoning_effort` = none|minimal|low|medium|high|xhigh|max (none and minimal +# disable thinking; doubao-seed-1-6-251015 accepts only the middle four). +# Plaintext reasoning is in +# `choices[].message.reasoning_content`, encrypted block is in +# `choices[].message.encrypted_content` (returned by default). +# Responses: POST /api/v3/responses; `reasoning.effort`; encrypted block requires +# explicit `include = ["reasoning.encrypted_content"]`. +# Anthropic: POST /api/v3/compatible/v1/messages; `thinking.type`, +# `output_config.effort` = low|high|max; plaintext thinking is in +# `content[thinking].thinking`, signature in `content[thinking].signature` (returned by default). +# Sources: +# https://www.volcengine.com/docs/82379/1298459 +# https://www.volcengine.com/docs/82379/1330310 +# https://www.volcengine.com/docs/82379/2636748 +doc = "https://www.volcengine.com/docs/82379/1330310" +api = "https://ark.cn-beijing.volces.com/api/v3" diff --git a/sdk/nexent/core/model_service/profiles/request_parameters.py b/sdk/nexent/core/model_service/profiles/request_parameters.py new file mode 100644 index 0000000000..e0d3b82e55 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/request_parameters.py @@ -0,0 +1,109 @@ +"""Controlled body-only extra parameter validation and JSON Pointer application.""" + +import json +import re +from typing import Mapping, Sequence + +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, _json_copy + + +_CONTROLLED_PATHS = ("/model", "/messages", "/tools", "/tool_choice", "/stream", "/stream_options") +_MAX_BYTES, _MAX_DEPTH, _MAX_LEAVES = 64 * 1024, 16, 128 + + +def _clone(value: object): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + return copied, encoded + except (ValueError, TypeError, RecursionError, UnicodeError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID") from None + + +def _tokens(path: object) -> tuple[str, ...]: + if not isinstance(path, str) or not path.startswith("/"): + raise ParameterValidationError("API_PATH_INVALID") + parts = path[1:].split("/") + if any(re.search(r"~(?![01])", part) for part in parts): + raise ParameterValidationError("API_PATH_INVALID", path) + return tuple(part.replace("~1", "/").replace("~0", "~") for part in parts) + + +def _overlap(left: tuple[str, ...], right: tuple[str, ...]) -> bool: + length = min(len(left), len(right)) + return left[:length] == right[:length] + + +def _leaves(value: object, depth: int = 0) -> int: + if depth > _MAX_DEPTH: + raise ParameterValidationError("EXTRA_API_PARAMETERS_LIMIT", keyword="depth") + if isinstance(value, dict): + return sum(_leaves(item, depth + 1) for item in value.values()) if value else 1 + if isinstance(value, list): + return sum(_leaves(item, depth + 1) for item in value) if value else 1 + return 1 + + +def _array_index(token: str, size: int, path: str) -> int: + if not re.fullmatch(r"0|[1-9][0-9]*", token): + raise ParameterValidationError("API_PATH_INVALID", path) + # Bound before integer conversion to reject oversized index strings safely. + if len(token) > len(str(size)) or int(token) >= size: + raise ParameterValidationError("API_PATH_INVALID", path) + return int(token) + + +def _set(body: dict, tokens: tuple[str, ...], value: object, path: str) -> None: + current = body + for token in tokens[:-1]: + if isinstance(current, dict): + if token not in current: + current[token] = {} + current = current[token] + elif isinstance(current, list): + current = current[_array_index(token, len(current), path)] + else: + raise ParameterValidationError("API_PATH_CONFLICT", path) + last = tokens[-1] + if isinstance(current, dict): + current[last] = value + elif isinstance(current, list): + current[_array_index(last, len(current), path)] = value + else: + raise ParameterValidationError("API_PATH_CONFLICT", path) + + +def apply_extra_api_parameters( + body: Mapping[str, object], + extras: Sequence[Mapping[str, object]], + *, + parameter_paths: Sequence[str] = (), + protected_paths: Sequence[str] = (), +) -> dict: + """Apply validated extras to a detached body, never to headers, URL or auth.""" + copied_body, _ = _clone(body) + copied_extras, encoded = _clone(extras) + if not isinstance(copied_body, dict) or not isinstance(copied_extras, list): + raise ParameterValidationError("PARAMETER_JSON_INVALID") + if len(encoded) > _MAX_BYTES: + raise ParameterValidationError("EXTRA_API_PARAMETERS_LIMIT", keyword="bytes") + protected = [_tokens(path) for path in (*_CONTROLLED_PATHS, *parameter_paths, *protected_paths)] + accepted, leaf_count = [], 0 + for entry in copied_extras: + if not isinstance(entry, dict) or set(entry) != {"path", "value"}: + raise ParameterValidationError("EXTRA_API_PARAMETER_INVALID") + path = entry["path"] + tokens = _tokens(path) + if any(_overlap(tokens, controlled) for controlled in protected): + raise ParameterValidationError("RESERVED_API_PATH", path) + if any(_overlap(tokens, existing) for existing, _ in accepted): + raise ParameterValidationError("API_PATH_CONFLICT", path) + leaf_count += _leaves(entry["value"]) + if leaf_count > _MAX_LEAVES: + raise ParameterValidationError("EXTRA_API_PARAMETERS_LIMIT", keyword="leaves") + accepted.append((tokens, entry)) + for tokens, entry in accepted: + _set(copied_body, tokens, entry["value"], entry["path"]) + return copied_body diff --git a/sdk/nexent/core/model_service/profiles/resolved_profile.py b/sdk/nexent/core/model_service/profiles/resolved_profile.py new file mode 100644 index 0000000000..8cfd548574 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/resolved_profile.py @@ -0,0 +1,200 @@ +"""Strict complete outlet snapshot, with safe credential references only.""" + +import json +from typing import Annotated, Literal, Self + +from pydantic import Field, JsonValue, StringConstraints, field_validator, model_validator + +from nexent.core.model_service.profiles.contracts import ( + AtomicProfileReference, + InstructionMappingDefinition, + NonBlankString, + ProfileCapabilities, + ProfileContract, + VersionedReference, +) +from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules, validate_saved_parameter_values +from nexent.core.model_service.profiles.parameter_schema import _json_copy, json_values_equal +from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters + + +PositiveInt4 = Annotated[int, Field(gt=0, lt=2**31)] +Name = Annotated[str, StringConstraints(min_length=1, max_length=100, pattern=r"\S")] + + +class OutletIdentity(ProfileContract): + tenant_id: Name + display_name: Name + model_type: Name + provider_id: Name + request_model_id: Name + provider_key: NonBlankString | None + model_profile_key: NonBlankString | None + + +class CredentialReference(ProfileContract): + kind: Literal["model_record", "none"] + model_id: PositiveInt4 | None + + +class OutletConnection(ProfileContract): + base_url: Annotated[str, StringConstraints(min_length=1, max_length=500, pattern=r"\S")] + ssl_verify: bool + timeout_seconds: PositiveInt4 + concurrency_limit: PositiveInt4 | None + credential_ref: CredentialReference + + +class ConnectionRules(ProfileContract): + credential_kind: Literal["model_record", "none"] + relative_path: NonBlankString + method: Literal["POST", "GET"] + headers: dict[NonBlankString, str] + + +class OutletBindings(ProfileContract): + protocol: VersionedReference + auth: VersionedReference + adapter: VersionedReference + code_converter: VersionedReference + native_converter: VersionedReference + role_mapping: VersionedReference + + +class AtomicReferences(ProfileContract): + model: AtomicProfileReference | None + provider: AtomicProfileReference | None + provider_model: AtomicProfileReference | None + + +class InterpretationVersions(ProfileContract): + source_parser: VersionedReference | None + source_mapping: VersionedReference | None + supplement_schema: VersionedReference | None + composer: VersionedReference | None + + +class OverrideRecord(ProfileContract): + path: NonBlankString + source: NonBlankString + original_value: JsonValue = Field(repr=False) + reason: NonBlankString + evidence: NonBlankString | list[NonBlankString] + + +class FieldSourceRecord(ProfileContract): + target_path: NonBlankString + source_path: NonBlankString + atom_kind: Literal["model", "provider", "provider_model"] + rule_id: NonBlankString + rule_version: PositiveInt4 + + +class OutletProvenance(ProfileContract): + atomic_refs: AtomicReferences + interpretation_versions: InterpretationVersions + overrides: list[OverrideRecord] + field_sources: list[FieldSourceRecord] + provider_metadata: dict[str, JsonValue] = Field(repr=False) + + +class ContextConfiguration(ProfileContract): + output_reserve_tokens: PositiveInt4 | None + tokenizer_family: Name | None + capacity_source: Name | None + capability_profile_version: Name | None + + +class ResolvedModelProfile(ProfileContract): + """A configuration snapshot; declarations do not prove registry availability.""" + + schema_version: Literal[1] + outlet_id: PositiveInt4 | None + config_revision: PositiveInt4 + identity: OutletIdentity + connection: OutletConnection + connection_rules: ConnectionRules + context_configuration: ContextConfiguration + capabilities: ProfileCapabilities + instruction_mapping: InstructionMappingDefinition + parameter_rules: list[dict] = Field(repr=False) + parameter_values: dict[str, JsonValue] = Field(repr=False) + extra_api_parameters: list[dict] = Field(repr=False) + bindings: OutletBindings + provenance: OutletProvenance + + @field_validator("schema_version", mode="before") + @classmethod + def validate_schema_version(cls, value): + if type(value) is not int: + raise ValueError("Schema version must be a strict integer") + return value + + @property + def provider_key(self) -> str | None: + reference = self.provenance.atomic_refs.provider + return reference.key if reference else None + + @property + def model_profile_key(self) -> str | None: + reference = self.provenance.atomic_refs.model + return reference.key if reference else None + + @model_validator(mode="after") + def validate_snapshot(self) -> Self: + credential = self.connection.credential_ref + if credential.model_id != self.outlet_id or credential.kind != self.connection_rules.credential_kind: + raise ValueError("Credential reference does not belong to this outlet") + if ( + self.identity.provider_key != self.provider_key + or self.identity.model_profile_key != self.model_profile_key + ): + raise ValueError("Atomic identity does not match its provenance") + if len(self.bindings.protocol.ref) > 30: + raise ValueError("Protocol reference exceeds its database column") + if validate_instruction_mapping(self.instruction_mapping) != self.bindings.role_mapping: + raise ValueError("Instruction mapping reference does not match its definition") + compiled = compile_parameter_rules(self.parameter_rules) + validate_saved_parameter_values(compiled, self.parameter_values) + declarations = {rule["key"]: rule for rule in compiled.declarations} + for control in self.capabilities.reasoning.controls or []: + key = control.parameter_key + if key not in declarations: + raise ValueError("Reasoning control has no parameter declaration") + if key not in self.parameter_values: + continue + value = self.parameter_values[key] + if control.mode in {"toggle", "effort"}: + valid = any(json_values_equal(value, candidate) for candidate in control.values) + else: + valid = type(value) is int and ( + value in control.special_values + or ( + (control.min is None or value >= control.min) and (control.max is None or value <= control.max) + ) + ) + if not valid: + raise ValueError("Saved value contradicts its reasoning control domain") + transforms = [] + for rule in self.parameter_rules: + binding = rule.get("api_binding") + if binding is not None: + if not isinstance(binding, dict) or set(binding) != {"path", "transform_ref", "transform_version"}: + raise ValueError("Invalid parameter binding declaration") + transforms.append( + VersionedReference.model_validate( + {"ref": binding["transform_ref"], "version": binding["transform_version"]} + ) + ) + # This checks declaration syntax only; storage also requires a trusted availability snapshot. + bound = validate_parameter_bindings(compiled, transforms) + parameter_paths = [binding["path"] for binding in bound.values()] + apply_extra_api_parameters({}, self.extra_api_parameters, parameter_paths=parameter_paths) + encoded = json.dumps(_json_copy(self.model_dump(mode="json")), ensure_ascii=False, allow_nan=False).encode( + "utf-8" + ) + if len(encoded) > 1024 * 1024: + raise ValueError("Profile exceeds the 1 MiB configuration limit") + return self diff --git a/sdk/nexent/core/model_service/profiles/resolver.py b/sdk/nexent/core/model_service/profiles/resolver.py new file mode 100644 index 0000000000..be99cc6da8 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/resolver.py @@ -0,0 +1,288 @@ +"""Atomic loading of pinned source profiles before Nexent supplements and API bindings.""" + +import hashlib +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles._models_dev.inheritance import EffectiveSourceModel, resolve_source_model +from nexent.core.model_service.profiles._models_dev.mapping import ( + MappedFieldOrigin, + map_source_fields, + parse_mapping_rules, +) +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceDocument, + SourceReadError, + _freeze, + _read, + read_inventory, +) +from nexent.core.model_service.profiles._models_dev.validation import validate_source_document +from nexent.core.model_service.profiles.contracts import ( + AtomicProfileReference, + ContextCapabilities, + ReasoningCapabilities, +) + + +PARSER_REF = "models_dev_source_catalog_v1" +SUPPORTED_UPSTREAM_REVISION = "e69e1844210e5cfe7c7e7f68d57328bc7451a51c" +MAPPING_PATH = "rules/mappings/models_dev/v1.toml" + + +@dataclass(frozen=True) +class CatalogFieldOrigin: + mapping: MappedFieldOrigin + target_path: str + normalization_ref: str = PARSER_REF + + +@dataclass(frozen=True) +class SourceCatalogEntry: + """Source-only declaration, not a complete ModelProfile or usable outlet.""" + + kind: str + key: str + identity: Mapping[str, object] + capabilities: Mapping[str, object] + reference_metadata: Mapping[str, object] + connection_suggestions: Mapping[str, object] + field_origins: Mapping[str, CatalogFieldOrigin] + source_refs: tuple[AtomicProfileReference, ...] + profile_revision: str + + +@dataclass(frozen=True) +class SourceCatalog: + revision: str + upstream_revision: str + parser_ref: str + mapping_id: str + mapping_version: int + models: Mapping[str, SourceCatalogEntry] + providers: Mapping[str, SourceCatalogEntry] + provider_models: Mapping[str, SourceCatalogEntry] + + +def _error(category: str, document: SourceDocument, path: str = "") -> SourceReadError: + error = SourceReadError(category, document.file_path, document.kind) + error.source_path = path + error.rule_id = PARSER_REF + return error + + +def _revision(parts: Mapping[str, str]) -> str: + digest = hashlib.sha256() + for key, value in sorted(parts.items()): + for item in (key, value): + data = item.encode("utf-8") + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return digest.hexdigest() + + +def _tree(values: Mapping[str, object]) -> dict: + """Materialize canonical pointer fields, preserving explicit arrays and nulls.""" + root = {} + for pointer, value in values.items(): + parts = pointer.split("/")[1:] + current = root + for index, part in enumerate(parts): + last = index == len(parts) - 1 + key = int(part) if isinstance(current, list) else part + if isinstance(current, list): + while len(current) <= key: + current.append(None) + if last: + current[key] = value + else: + next_value = [] if parts[index + 1].isdigit() else {} + if isinstance(current, list): + if current[key] is None: + current[key] = next_value + elif key not in current: + current[key] = next_value + current = current[key] + return root + + +def _controls(reasoning: dict) -> list | None: + if "control_candidates" not in reasoning: + return None + controls = [] + for option in reasoning["control_candidates"]: + mode = option["type"] + if mode == "toggle": + controls.append({"mode": "toggle", "values": [True, False]}) + elif mode == "effort": + controls.append( + {"mode": "effort", "values": [None if value == "null" else value for value in option["values"]]} + ) + else: + lower = option.get("min") + controls.append( + { + "mode": "budget_tokens", + "min": None if lower == -1 else lower, + "max": option.get("max"), + "special_values": [-1] if lower == -1 else [], + } + ) + return controls + + +def _normalized_origins(mapped, controls) -> dict: + origins = {} + for path, origin in mapped.field_origins.items(): + target = path + if path.startswith("/reasoning/control_candidates"): + target = path.replace("/reasoning/control_candidates", "/capabilities/reasoning/controls", 1) + target = re.sub(r"/type$", "/mode", target) + if path.endswith("/min") and mapped.values[path] == -1: + target = target[: -len("min")] + "special_values/0" + elif path.split("/")[1] in {"context", "reasoning", "tools", "sampling", "structured_output", "modalities"}: + target = "/capabilities" + path + origins[target] = CatalogFieldOrigin(origin, target) + for index, control in enumerate(controls or ()): + prefix = f"/capabilities/reasoning/controls/{index}" + type_origin = origins[prefix + "/mode"].mapping + origins[prefix + "/parameter_key"] = CatalogFieldOrigin(type_origin, prefix + "/parameter_key") + if control["mode"] == "toggle": + for value_index in range(2): + path = prefix + f"/values/{value_index}" + origins[path] = CatalogFieldOrigin(type_origin, path) + return origins + + +def _entry( + document: SourceDocument, source, registry, providers: Mapping, revision: str, version_parts: dict +) -> SourceCatalogEntry: + mapped = map_source_fields(source, registry) + tree = _tree(mapped.values) + unexpected = set(tree) - { + "identity", + "context", + "reasoning", + "tools", + "sampling", + "structured_output", + "modalities", + "reference_metadata", + "connection_suggestions", + } + if unexpected: + raise _error("SOURCE_TARGET_UNHANDLED", document) + reasoning = tree.get("reasoning", {}) + controls = _controls(reasoning) + try: + context = ContextCapabilities.model_validate(tree.get("context", {})) + except ValidationError: + raise _error("SOURCE_NORMALIZATION_INVALID", document, "limit") from None + try: + thinking = ReasoningCapabilities(status=reasoning.get("status", "unknown"), controls=controls) + except ValidationError: + raise _error("SOURCE_NORMALIZATION_INVALID", document, "reasoning_options") from None + capabilities = { + "context": context.model_dump(), + "reasoning": thinking.model_dump(), + "tools": tree.get("tools", {"status": "unknown"}), + "sampling": tree.get("sampling", {}), + "structured_output": tree.get("structured_output", {"status": "unknown"}), + "modalities": tree.get("modalities", {"input": None, "output": None}), + } + if document.kind == "provider": + capabilities = {} + dependencies = source.dependencies if isinstance(source, EffectiveSourceModel) else (document,) + if document.kind == "provider_model": + provider_key = document.key.split("/", 1)[0] + if provider_key not in providers: + raise _error("SOURCE_PROVIDER_MISSING", document) + dependencies = (*dependencies, providers[provider_key]) + profile_parts = dict(version_parts) + refs = [] + for dependency in dependencies: + profile_parts[dependency.file_path] = dependency.content_sha256 + refs.append( + AtomicProfileReference( + key=dependency.key, + catalog_revision=revision, + file_path=dependency.file_path, + content_sha256=dependency.content_sha256, + ) + ) + return SourceCatalogEntry( + document.kind, + document.key, + _freeze(tree.get("identity", {})), + _freeze(capabilities), + _freeze(tree.get("reference_metadata", {})), + _freeze(tree.get("connection_suggestions", {})), + _freeze(_normalized_origins(mapped, controls)), + tuple(refs), + _revision(profile_parts), + ) + + +def load_source_catalog(root: Path | None = None) -> SourceCatalog: + """Publish only a fully validated immutable source catalog, without caching partial results.""" + root = (root if root is not None else Path(__file__).parent).resolve() + inventory = read_inventory(root) + if inventory.upstream_revision != SUPPORTED_UPSTREAM_REVISION: + error = SourceReadError("SOURCE_SCHEMA_UNSUPPORTED", "manifest.toml", "manifest") + error.source_path = "upstream_revision" + error.rule_id = PARSER_REF + raise error + rule_bytes = _read(root, MAPPING_PATH, "mapping") + registry = parse_mapping_rules(rule_bytes, MAPPING_PATH) + version_parts = { + "parser_ref": PARSER_REF, + "source_schema_ref": inventory.source_schema_ref, + "mapping_ref": registry.mapping_id, + "mapping_version": str(registry.mapping_version), + "mapping_sha256": hashlib.sha256(rule_bytes).hexdigest(), + "upstream_revision": inventory.upstream_revision, + } + catalog_parts = { + **version_parts, + "manifest_sha256": inventory.manifest_sha256, + **{document.file_path: document.content_sha256 for document in inventory.documents}, + } + revision = _revision(catalog_parts) + providers = {document.key: document for document in inventory.documents if document.kind == "provider"} + entries = {"model": {}, "provider": {}, "provider_model": {}} + for document in inventory.documents: + validate_source_document(document) + for document in inventory.documents: + source = document + if document.kind != "provider": + source = resolve_source_model(document, inventory.documents) + effective = SourceDocument( + document.kind, document.key, document.file_path, document.content_sha256, source.data + ) + validate_source_document(effective, effective=True) + entries[document.kind][document.key] = _entry(document, source, registry, providers, revision, version_parts) + return SourceCatalog( + revision, + inventory.upstream_revision, + PARSER_REF, + registry.mapping_id, + registry.mapping_version, + _freeze(entries["model"]), + _freeze(entries["provider"]), + _freeze(entries["provider_model"]), + ) + + +def load_profile_catalog(root: Path | None = None): + """Load source facts and explicit Nexent policy through one atomic entry point.""" + from nexent.core.model_service.profiles.composition import build_profile_catalog + from nexent.core.model_service.profiles.supplements import load_supplements + + root = (root if root is not None else Path(__file__).parent).resolve() + sources = load_source_catalog(root) + supplements = load_supplements(root, sources) + return build_profile_catalog(sources, supplements) diff --git a/sdk/nexent/core/model_service/profiles/rules/manifest.toml b/sdk/nexent/core/model_service/profiles/rules/manifest.toml new file mode 100644 index 0000000000..a07ff4661f --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/rules/manifest.toml @@ -0,0 +1,13 @@ +schema_version = 1 + +[[rules]] +path = "rules/models/alibaba/qwen3.5-plus.toml" +content_sha256 = "4d224c51db0164b25333be6292764747060d53e1d69ff6feb2f12e32495876cd" + +[[rules]] +path = "rules/providers/alibaba-cn/provider.toml" +content_sha256 = "1defa8dbcc173c84db79c9fad31c17bc570033951bcbbe7f40dfcfa8080343ab" + +[[rules]] +path = "rules/providers/alibaba-cn/qwen3.5-plus.toml" +content_sha256 = "d9d1144d7d6ca02686aceefd4a8a1658838b0e24121d8778e461e20566b51315" diff --git a/sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml b/sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml new file mode 100644 index 0000000000..1a9c98cddf --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml @@ -0,0 +1,1417 @@ +# Explicit source dispositions; API bindings are managed separately. +mapping_id = "models_dev_source_v1" +mapping_version = 1 + +[[rules]] +rule_id = "model.name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "name" +source_type = "string" +target_path = "/identity/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.description" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "description" +source_type = "string" +target_path = "/identity/description" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.family" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "family" +source_type = "string" +target_path = "/identity/family" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.attachment" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "attachment" +source_type = "boolean" +target_path = "/reference_metadata/attachment" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "reasoning" +source_type = "boolean" +target_path = "/reasoning/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.tool_call" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "tool_call" +source_type = "boolean" +target_path = "/tools/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.structured_output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "structured_output" +source_type = "boolean" +target_path = "/structured_output/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.temperature" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "temperature" +source_type = "boolean" +target_path = "/sampling/temperature/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.open_weights" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "open_weights" +source_type = "boolean" +target_path = "/reference_metadata/open_weights" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.knowledge" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "knowledge" +source_type = "string" +target_path = "/reference_metadata/knowledge" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.release_date" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "release_date" +source_type = "string" +target_path = "/reference_metadata/release_date" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.last_updated" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "last_updated" +source_type = "string" +target_path = "/reference_metadata/last_updated" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities" +source_type = "object" +target_path = "/modalities" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.input" +source_type = "array" +target_path = "/modalities/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.input[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.input[]" +source_type = "string" +target_path = "/modalities/input[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.output" +source_type = "array" +target_path = "/modalities/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.output[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.output[]" +source_type = "string" +target_path = "/modalities/output[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit" +source_type = "object" +target_path = "/reference_metadata/limit" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit.context" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit.context" +source_type = "integer" +target_path = "/context/combined_window" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit.input" +source_type = "integer" +target_path = "/context/input_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit.output" +source_type = "integer" +target_path = "/context/output_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.license" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "license" +source_type = "string" +target_path = "/reference_metadata/license" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links" +source_type = "array" +target_path = "/reference_metadata/links" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[]" +source_type = "object" +target_path = "/reference_metadata/links[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[].url" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[].url" +source_type = "string" +target_path = "/reference_metadata/links[]/url" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[].label" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[].label" +source_type = "string" +target_path = "/reference_metadata/links[]/label" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[].type" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[].type" +source_type = "string" +target_path = "/reference_metadata/links[]/type" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights" +source_type = "array" +target_path = "/reference_metadata/weights" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[]" +source_type = "object" +target_path = "/reference_metadata/weights[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].url" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].url" +source_type = "string" +target_path = "/reference_metadata/weights[]/url" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].label" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].label" +source_type = "string" +target_path = "/reference_metadata/weights[]/label" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].format" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].format" +source_type = "string" +target_path = "/reference_metadata/weights[]/format" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].quantization" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].quantization" +source_type = "string" +target_path = "/reference_metadata/weights[]/quantization" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks" +source_type = "array" +target_path = "/reference_metadata/benchmarks" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[]" +source_type = "object" +target_path = "/reference_metadata/benchmarks[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].name" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].score" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].score" +source_type = "number_or_string" +target_path = "/reference_metadata/benchmarks[]/score" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].metric" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].metric" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/metric" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].harness" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].harness" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/harness" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].variant" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].variant" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/variant" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].dataset" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].dataset" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/dataset" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].version" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].version" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/version" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].source" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].source" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/source" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].date" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].date" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/date" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "name" +source_type = "string" +target_path = "/identity/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.env" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "env" +source_type = "array" +target_path = "/reference_metadata/env" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.env[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "env[]" +source_type = "string" +target_path = "/reference_metadata/env[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.npm" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "npm" +source_type = "string" +target_path = "/reference_metadata/npm" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.api" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "api" +source_type = "string" +target_path = "/connection_suggestions/base_url" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.doc" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "doc" +source_type = "string" +target_path = "/reference_metadata/doc" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "name" +source_type = "string" +target_path = "/identity/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.description" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "description" +source_type = "string" +target_path = "/identity/description" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.family" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "family" +source_type = "string" +target_path = "/identity/family" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.attachment" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "attachment" +source_type = "boolean" +target_path = "/reference_metadata/attachment" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning" +source_type = "boolean" +target_path = "/reasoning/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.tool_call" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "tool_call" +source_type = "boolean" +target_path = "/tools/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.structured_output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "structured_output" +source_type = "boolean" +target_path = "/structured_output/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.temperature" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "temperature" +source_type = "boolean" +target_path = "/sampling/temperature/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.open_weights" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "open_weights" +source_type = "boolean" +target_path = "/reference_metadata/open_weights" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.knowledge" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "knowledge" +source_type = "string" +target_path = "/reference_metadata/knowledge" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.release_date" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "release_date" +source_type = "string" +target_path = "/reference_metadata/release_date" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.last_updated" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "last_updated" +source_type = "string" +target_path = "/reference_metadata/last_updated" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities" +source_type = "object" +target_path = "/modalities" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.input" +source_type = "array" +target_path = "/modalities/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.input[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.input[]" +source_type = "string" +target_path = "/modalities/input[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.output" +source_type = "array" +target_path = "/modalities/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.output[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.output[]" +source_type = "string" +target_path = "/modalities/output[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit" +source_type = "object" +target_path = "/reference_metadata/limit" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit.context" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit.context" +source_type = "integer" +target_path = "/context/combined_window" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit.input" +source_type = "integer" +target_path = "/context/input_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit.output" +source_type = "integer" +target_path = "/context/output_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options" +source_type = "array" +target_path = "/reasoning/control_candidates" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[]" +source_type = "object" +target_path = "/reasoning/control_candidates[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].type" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].type" +source_type = "string" +target_path = "/reasoning/control_candidates[]/type" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].values" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].values" +source_type = "array" +target_path = "/reasoning/control_candidates[]/values" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].values[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].values[]" +source_type = "string" +target_path = "/reasoning/control_candidates[]/values[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].min" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].min" +source_type = "integer" +target_path = "/reasoning/control_candidates[]/min" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].max" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].max" +source_type = "integer" +target_path = "/reasoning/control_candidates[]/max" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.interleaved" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "interleaved" +source_type = "boolean" +target_path = "/reference_metadata/interleaved" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.interleaved.field" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "interleaved.field" +source_type = "string" +target_path = "/reference_metadata/interleaved/field" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost" +source_type = "object" +target_path = "/reference_metadata/cost" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.input" +source_type = "number" +target_path = "/reference_metadata/cost/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.output" +source_type = "number" +target_path = "/reference_metadata/cost/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.reasoning" +source_type = "number" +target_path = "/reference_metadata/cost/reasoning" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.cache_read" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.cache_read" +source_type = "number" +target_path = "/reference_metadata/cost/cache_read" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.cache_write" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.cache_write" +source_type = "number" +target_path = "/reference_metadata/cost/cache_write" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.input_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.input_audio" +source_type = "number" +target_path = "/reference_metadata/cost/input_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.output_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.output_audio" +source_type = "number" +target_path = "/reference_metadata/cost/output_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers" +source_type = "array" +target_path = "/reference_metadata/cost/tiers" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[]" +source_type = "object" +target_path = "/reference_metadata/cost/tiers[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].input" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].output" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].reasoning" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/reasoning" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].cache_read" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].cache_read" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/cache_read" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].cache_write" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].cache_write" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/cache_write" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].input_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].input_audio" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/input_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].output_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].output_audio" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/output_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].tier" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].tier" +source_type = "object" +target_path = "/reference_metadata/cost/tiers[]/tier" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].tier.type" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].tier.type" +source_type = "string" +target_path = "/reference_metadata/cost/tiers[]/tier/type" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].tier.size" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].tier.size" +source_type = "integer" +target_path = "/reference_metadata/cost/tiers[]/tier/size" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.status" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "status" +source_type = "string" +target_path = "/reference_metadata/status" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" diff --git a/sdk/nexent/core/model_service/profiles/rules/models/alibaba/qwen3.5-plus.toml b/sdk/nexent/core/model_service/profiles/rules/models/alibaba/qwen3.5-plus.toml new file mode 100644 index 0000000000..f9a8d8f88e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/rules/models/alibaba/qwen3.5-plus.toml @@ -0,0 +1,12 @@ +schema_version = 1 +rule_id = "qwen35_plus_instruction_v1" +version = 1 +target_kind = "model" +target_key = "alibaba/qwen3.5-plus" +reason = "Use Nexent's initial traditional instruction strategy" +evidence = "Nexent model access SPEC: traditional instruction roles, strategy system_only_v1" +aliases = ["qwen3.5-plus"] + +[instruction_mapping] +ref = "system_only_v1" +version = 1 diff --git a/sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/provider.toml b/sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/provider.toml new file mode 100644 index 0000000000..997ee81276 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/provider.toml @@ -0,0 +1,8 @@ +schema_version = 1 +rule_id = "alibaba_cn_endpoint_hints_v1" +version = 1 +target_kind = "provider" +target_key = "alibaba-cn" +reason = "Retain the copied China endpoint as an explicit selection hint" +evidence = "models.dev pinned alibaba-cn/provider.toml api field; upstream e69e1844210e5cfe7c7e7f68d57328bc7451a51c" +endpoint_hints = ["https://dashscope.aliyuncs.com/compatible-mode/v1"] diff --git a/sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/qwen3.5-plus.toml b/sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/qwen3.5-plus.toml new file mode 100644 index 0000000000..e6de8c1cde --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/rules/providers/alibaba-cn/qwen3.5-plus.toml @@ -0,0 +1,117 @@ +schema_version = 1 +rule_id = "alibaba_cn_qwen35_plus_thinking_v1" +version = 3 +target_kind = "provider_model" +target_key = "alibaba-cn/qwen3.5-plus" +model_profile_key = "alibaba/qwen3.5-plus" +reason = "Declare the exact OpenAI-compatible thinking control without guessing from a capability flag" +evidence = "https://www.alibabacloud.com/help/zh/model-studio/deep-thinking (checked 2026-10-13)" + +[[capability_overrides]] +path = "/reasoning/controls" +value = [{mode = "toggle", parameter_key = "enable_thinking", values = [false, true]}, {mode = "budget_tokens", parameter_key = "reasoning_budget_tokens", max = 81920}] +reason = "Preserve the copied toggle and budget controls; register their distinct API bindings" +evidence = "https://www.alibabacloud.com/help/zh/model-studio/deep-thinking#supported-models (checked 2026-10-13)" + +[[parameters]] +key = "enable_thinking" +visible = true +editable = true +required = false +allowed_scopes = ["outlet", "conversation"] +ui_group = "reasoning" +owner = "combination" +default_value = true + +[parameters.schema] +type = "boolean" + +[parameters.source] +ref = "alibaba_cn_qwen35_plus_thinking_v1" +version = 1 +evidence = "https://www.alibabacloud.com/help/zh/model-studio/deep-thinking" + +[parameters.api_binding] +path = "/enable_thinking" +transform_ref = "identity_v1" +transform_version = 1 + + +[[parameters]] +key = "reasoning_budget_tokens" +visible = true +editable = true +required = false +allowed_scopes = ["outlet", "conversation"] +ui_group = "reasoning" +owner = "combination" + +[parameters.schema] +type = "integer" +maximum = 81920 + +[parameters.source] +ref = "alibaba_cn_qwen35_plus_thinking_v1" +version = 1 +evidence = "Pinned models.dev providers/alibaba-cn/models/qwen3.5-plus.toml: reasoning_options budget_tokens max=81920; lower bound remains unknown" + +[parameters.condition] +key = "enable_thinking" +op = "eq" +value = true + +[parameters.api_binding] +path = "/thinking_budget" +transform_ref = "identity_v1" +transform_version = 1 + +[[parameters]] +key = "stop" +visible = false +editable = true +required = false +allowed_scopes = ["outlet"] +ui_group = "runtime" +owner = "combination" + +[parameters.schema] +type = ["string", "array"] +minLength = 1 + +[parameters.schema.items] +type = "string" +minLength = 1 + +[parameters.source] +ref = "alibaba_cn_qwen35_plus_thinking_v1" +version = 2 +evidence = "https://www.alibabacloud.com/help/zh/model-studio/qwen-api-via-openai-chat-completions (checked 2026-10-14); string stop subset consumed by CodeAgent and planning" + +[parameters.api_binding] +path = "/stop" +transform_ref = "identity_v1" +transform_version = 1 + +[[parameters]] +key = "max_output_tokens" +visible = true +editable = true +required = false +allowed_scopes = ["outlet", "conversation"] +ui_group = "generation" +owner = "combination" + +[parameters.schema] +type = "integer" +minimum = 1 +maximum = 65536 + +[parameters.source] +ref = "alibaba_cn_qwen35_plus_thinking_v1" +version = 3 +evidence = "Pinned alibaba-cn/qwen3.5-plus limit.output=65536; https://www.alibabacloud.com/help/zh/model-studio/qwen-api-via-openai-chat-completions (checked 2026-10-14): max_tokens limits answer tokens for this model, excluding reasoning" + +[parameters.api_binding] +path = "/max_tokens" +transform_ref = "identity_v1" +transform_version = 1 diff --git a/sdk/nexent/core/model_service/profiles/supplements.py b/sdk/nexent/core/model_service/profiles/supplements.py new file mode 100644 index 0000000000..4ad745e90a --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/supplements.py @@ -0,0 +1,246 @@ +"""Read explicit Nexent supplement declarations separately from upstream TOML.""" + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + +from pydantic import Field, JsonValue, ValidationError, model_validator + +from nexent.core.model_service.profiles._models_dev.reader import SourceReadError, _freeze, _parse, _read +from nexent.core.model_service.profiles.contracts import ( + AtomicProfileReference, + NonBlankString, + ProfileContract, + VersionedReference, +) +from nexent.core.model_service.profiles.instruction_mapping import InstructionMappingError, resolve_instruction_mapping +from nexent.core.model_service.profiles.parameter_rules import ParameterRule, compile_parameter_rules +from nexent.core.model_service.profiles.parameter_schema import ( + ParameterValidationError, + compile_parameter_schema, + validate_parameter_value, +) +from nexent.core.model_service.profiles.resolver import SourceCatalog, _revision + + +MANIFEST_PATH = "rules/manifest.toml" +SUPPLEMENT_SCHEMA_REF = "nexent_supplements_v1" + + +class CapabilityOverride(ProfileContract): + """An explicit changed fact, accompanied by human-readable evidence.""" + + path: NonBlankString + value: JsonValue + reason: NonBlankString + evidence: NonBlankString + + +class SupplementBindings(ProfileContract): + """Declared versioned dependencies; availability is checked during composition.""" + + protocol: VersionedReference | None = None + auth: VersionedReference | None = None + adapter: VersionedReference | None = None + code_converter: VersionedReference | None = None + native_converter: VersionedReference | None = None + + +class SupplementRule(ProfileContract): + """One exact source target; no regex, dynamic imports or implicit name matching.""" + + schema_version: int = Field(ge=1, le=1) + rule_id: NonBlankString + version: int = Field(gt=0) + target_kind: Literal["model", "provider", "provider_model"] + target_key: NonBlankString + reason: NonBlankString + evidence: NonBlankString + model_profile_key: NonBlankString | None = None + aliases: list[NonBlankString] = Field(default_factory=list) + endpoint_hints: list[NonBlankString] = Field(default_factory=list) + instruction_mapping: VersionedReference | None = None + bindings: SupplementBindings | None = None + parameters: list[ParameterRule] = Field(default_factory=list) + capability_overrides: list[CapabilityOverride] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_ownership(self): + if len(set(self.aliases)) != len(self.aliases) or len(set(self.endpoint_hints)) != len(self.endpoint_hints): + raise ValueError("Supplement hints must be unique") + if self.model_profile_key is not None and self.target_kind != "provider_model": + raise ValueError("Only provider-model rules can link a model profile") + if self.aliases and self.target_kind != "model": + raise ValueError("Model aliases require model ownership") + if self.endpoint_hints and self.target_kind != "provider": + raise ValueError("Endpoint hints require provider ownership") + return self + + +class SupplementFile(ProfileContract): + path: NonBlankString + content_sha256: NonBlankString + + +class SupplementManifest(ProfileContract): + schema_version: int = Field(ge=1, le=1) + rules: list[SupplementFile] + + +class SupplementReadError(ValueError): + """Do not expose file contents, fixed values or operating system details.""" + + def __init__(self, category: str, file_path: str, field_path: str = ""): + self.category, self.file_path, self.field_path = category, file_path, field_path + super().__init__(f"{category}: {file_path}: {field_path}") + + +@dataclass(frozen=True) +class SupplementEntry: + file_path: str + content_sha256: str + rule: Mapping[str, object] + + +@dataclass(frozen=True) +class SupplementCatalog: + revision: str + schema_ref: str + entries: Mapping[str, SupplementEntry] + source_revision: str + + +def _document(root: Path, path: str) -> tuple[dict, bytes]: + try: + data = _read(root, path, "supplement") + return _parse(data, path, "supplement"), data + except SourceReadError: + raise SupplementReadError("SUPPLEMENT_FILE_INVALID", path) from None + + +def _decode_json_fields(authored: dict, file_path: str) -> dict: + """Explicitly encode JSON-only values; ordinary TOML strings keep their meaning.""" + copied = dict(authored) + directives = copied.pop("json_fields", {}) + if not isinstance(directives, dict): + raise SupplementReadError("SUPPLEMENT_JSON_FIELD_INVALID", file_path, "json_fields") + + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate JSON field") + result[key] = value + return result + + def invalid_constant(value): + raise ValueError("Nonfinite JSON value") + + for pointer, encoded in directives.items(): + match = re.fullmatch( + r"/(parameters|capability_overrides)/(0|[1-9][0-9]*)/(schema|default_value|fixed_value|condition|value)", + pointer, + ) + if match is None or not isinstance(encoded, str): + raise SupplementReadError("SUPPLEMENT_JSON_FIELD_INVALID", file_path, "json_fields") + group, index_text, field = match.groups() + allowed = { + "parameters": {"schema", "default_value", "fixed_value", "condition"}, + "capability_overrides": {"value"}, + } + items = copied.get(group) + if ( + field not in allowed[group] + or not isinstance(items, list) + or len(index_text) > len(str(len(items))) + or int(index_text) >= len(items) + or not isinstance(items[int(index_text)], dict) + ): + raise SupplementReadError("SUPPLEMENT_JSON_FIELD_INVALID", file_path, pointer) + item = items[int(index_text)] + if field in item: + raise SupplementReadError("SUPPLEMENT_JSON_FIELD_CONFLICT", file_path, pointer) + try: + value = json.loads(encoded, object_pairs_hook=unique_object, parse_constant=invalid_constant) + validate_parameter_value(compile_parameter_schema({}), value) + except (ValueError, RecursionError): + raise SupplementReadError("SUPPLEMENT_JSON_FIELD_INVALID", file_path, pointer) from None + # Copy the touched container rather than mutating the authored declaration. + items = [dict(entry) if isinstance(entry, dict) else entry for entry in items] + items[int(index_text)][field] = value + copied[group] = items + return copied + + +def load_supplements(root: Path, sources: SourceCatalog) -> SupplementCatalog: + """Read a complete immutable declaration catalog; final policy composition follows.""" + root = root.resolve() + authored, manifest_bytes = _document(root, MANIFEST_PATH) + try: + manifest = SupplementManifest.model_validate(authored) + except ValidationError as error: + location = "/".join(str(part) for part in error.errors(include_input=False)[0]["loc"]) + raise SupplementReadError("SUPPLEMENT_MANIFEST_INVALID", MANIFEST_PATH, location) from None + entries, paths, targets = {}, set(), set() + parts = { + "schema_ref": SUPPLEMENT_SCHEMA_REF, + "source_catalog": sources.revision, + "manifest": hashlib.sha256(manifest_bytes).hexdigest(), + } + available = {"model": sources.models, "provider": sources.providers, "provider_model": sources.provider_models} + for declared in manifest.rules: + path = declared.path + try: + AtomicProfileReference( + key="supplement", + catalog_revision=sources.revision, + file_path=path, + content_sha256=declared.content_sha256, + ) + except ValidationError: + raise SupplementReadError("SUPPLEMENT_PATH_INVALID", path) from None + if not path.startswith(("rules/models/", "rules/providers/")) or not path.endswith(".toml") or path in paths: + raise SupplementReadError("SUPPLEMENT_PATH_INVALID", path) + paths.add(path) + authored, data = _document(root, path) + digest = hashlib.sha256(data).hexdigest() + if digest != declared.content_sha256: + raise SupplementReadError("SUPPLEMENT_HASH_MISMATCH", path) + try: + validate_parameter_value(compile_parameter_schema({}), authored) + except ParameterValidationError as error: + raise SupplementReadError("SUPPLEMENT_RULE_INVALID", path, error.path) from None + authored = _decode_json_fields(authored, path) + try: + rule = SupplementRule.model_validate(authored) + # Conditions can refer to parameters in another participating atom. + # Compile the combined dependency graph only after profile composition. + declarations = [parameter.model_dump(by_alias=True, exclude_unset=True) for parameter in rule.parameters] + compile_parameter_rules([{**declaration, "condition": None} for declaration in declarations]) + if rule.instruction_mapping is not None: + resolve_instruction_mapping(rule.instruction_mapping) + except ValidationError as error: + location = "/".join(str(part) for part in error.errors(include_input=False)[0]["loc"]) + raise SupplementReadError("SUPPLEMENT_RULE_INVALID", path, location) from None + except ParameterValidationError as error: + raise SupplementReadError("SUPPLEMENT_RULE_INVALID", path, "parameters" + error.path) from None + except InstructionMappingError: + raise SupplementReadError("SUPPLEMENT_RULE_INVALID", path, "instruction_mapping") from None + target = (rule.target_kind, rule.target_key) + expected_directory = "rules/models/" if rule.target_kind == "model" else "rules/providers/" + if not path.startswith(expected_directory): + raise SupplementReadError("SUPPLEMENT_PATH_OWNERSHIP", path) + if target in targets or rule.rule_id in entries: + raise SupplementReadError("SUPPLEMENT_DUPLICATE", path) + if rule.target_key not in available[rule.target_kind]: + raise SupplementReadError("SUPPLEMENT_TARGET_MISSING", path) + if rule.model_profile_key is not None and rule.model_profile_key not in sources.models: + raise SupplementReadError("SUPPLEMENT_MODEL_MISSING", path) + targets.add(target) + normalized = rule.model_dump(mode="json", by_alias=True, exclude_unset=True) + entries[rule.rule_id] = SupplementEntry(path, digest, _freeze(normalized)) + parts[path] = digest + return SupplementCatalog(_revision(parts), SUPPLEMENT_SCHEMA_REF, _freeze(entries), sources.revision) diff --git a/sdk/nexent/core/model_service/registry.py b/sdk/nexent/core/model_service/registry.py new file mode 100644 index 0000000000..7631a86246 --- /dev/null +++ b/sdk/nexent/core/model_service/registry.py @@ -0,0 +1,110 @@ +"""Explicit versioned adapter registration, independent of persisted import strings.""" + +from types import MappingProxyType + +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError, OpenAIChatAdapter +from nexent.core.model_service.http_contracts import ModelTransportError, validate_headers +from nexent.core.model_service.http_sender import HttpRequestSender +from nexent.core.model_service.parameter_transforms import parameter_transform_references +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.resource_conversion import ResourceConverterRegistry + + +class AdapterRegistry: + """A detached immutable directory of trusted, already-constructed implementations.""" + + def __init__(self, adapters=None): + instances = (OpenAIChatAdapter(),) if adapters is None else tuple(adapters) + entries = {} + for adapter in instances: + reference = getattr(adapter, "reference", None) + if not isinstance(reference, VersionedReference): + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_INVALID") + if not isinstance(getattr(adapter, "protocol_reference", None), VersionedReference): + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_INVALID") + key = (reference.ref, reference.version) + if key in entries: + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_DUPLICATE") + methods = ("prepare_http_request", "decode_response", "decode_stream", "adecode_stream", "normalize_error") + if any(not callable(getattr(adapter, method, None)) for method in methods): + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_INVALID") + entries[key] = adapter + self._entries = MappingProxyType(entries) + + def resolve(self, reference: VersionedReference): + if not isinstance(reference, VersionedReference): + raise ModelAdapterError("MODEL_ADAPTER_UNREGISTERED") + adapter = self._entries.get((reference.ref, reference.version)) + if adapter is None: + raise ModelAdapterError("MODEL_ADAPTER_UNREGISTERED") + return adapter + + @property + def references(self) -> tuple[VersionedReference, ...]: + return tuple(VersionedReference(ref=key, version=version) for key, version in self._entries) + + @property + def protocol_references(self) -> tuple[VersionedReference, ...]: + keys = sorted({(adapter.protocol_reference.ref, adapter.protocol_reference.version) + for adapter in self._entries.values()}) + return tuple(VersionedReference(ref=key, version=version) for key, version in keys) + + +def _bearer_headers(lookup, tenant_id, reference): + if reference.kind != "model_record": + raise ModelTransportError("CREDENTIAL_SCHEME_MISMATCH") + value = lookup(tenant_id, reference) + if not isinstance(value, str) or not value.strip(): + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") + return validate_headers({"authorization": "Bearer " + value}, credentials=True) + + +def _no_auth_headers(lookup, tenant_id, reference): + if reference.kind != "none": + raise ModelTransportError("CREDENTIAL_SCHEME_MISMATCH") + return {} + + +class AuthenticationRegistry: + """Trusted auth functions; credential lookup remains tenant-scoped and injected.""" + + def __init__(self): + self._entries = MappingProxyType({ + ("bearer_token_v1", 1): _bearer_headers, + ("no_auth_v1", 1): _no_auth_headers, + }) + + @property + def references(self) -> tuple[VersionedReference, ...]: + return tuple(VersionedReference(ref=key, version=version) for key, version in self._entries) + + def sender_for(self, profile, lookup) -> HttpRequestSender: + reference = profile.bindings.auth + scheme = self._entries.get((reference.ref, reference.version)) + if scheme is None: + raise ModelAdapterError("MODEL_AUTH_UNREGISTERED") + credential = profile.connection.credential_ref + tenant_id = profile.identity.tenant_id + + def resolve(submitted_reference): + if submitted_reference != credential: + raise ModelTransportError("CREDENTIAL_REFERENCE_MISMATCH") + try: + return scheme(lookup, tenant_id, credential) + except Exception: + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") from None + + return HttpRequestSender(resolve) + + +def model_service_references(): + """Report executable local registrations for composing and restoring outlets.""" + adapters, authentication, converters = AdapterRegistry(), AuthenticationRegistry(), ResourceConverterRegistry() + return MappingProxyType({ + "protocol": adapters.protocol_references, + "adapter": adapters.references, + "auth": authentication.references, + "code_converter": converters.references("code_converter"), + "native_converter": converters.references("native_converter"), + "parameter_transform": parameter_transform_references(), + }) diff --git a/sdk/nexent/core/model_service/resource_conversion.py b/sdk/nexent/core/model_service/resource_conversion.py new file mode 100644 index 0000000000..7cf756d0fa --- /dev/null +++ b/sdk/nexent/core/model_service/resource_conversion.py @@ -0,0 +1,102 @@ +"""Convert already-authorized resources; execution and prompt policy stay in Runtime.""" + +import json +from typing import Literal +from uuid import uuid4 + +from nexent.core.agent_runtime_support.contracts import RuntimeResourceView as _RuntimeResourceView +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.profiles.contracts import ProfileContract, VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping + + +class ConvertedModelRequest(ProfileContract): + request: ModelRequest + tool_protocol: Literal["native", "text_block"] + + +def _text(data): + return {"type": "text", "text": json.dumps(data, ensure_ascii=False, allow_nan=False)} + + +def _code_message(message): + value = message.model_dump() + value["reasoning_parts"] = [] # Diagnostic history is not a portable provider reasoning continuation. + if message.tool_calls: + value["content_parts"].append(_text({ + "historical_tool_calls": [ + {"id": call.provider_call_id, "name": call.name, "arguments": call.raw_arguments} + for call in message.tool_calls + ], + })) + value["tool_calls"] = [] + if message.semantic_role in {"tool_result", "tool"}: + value["semantic_role"] = "reference_data" + value["content_parts"].insert(0, _text({"historical_tool_result": message.tool_call_id})) + value["tool_call_id"] = None + return ModelMessage.model_validate(value) + + +class ResourceRequestConverter: + """One registered conversion per mode, chosen before any model request.""" + + def __init__(self, *, native=False): + if type(native) is not bool: + raise TypeError("Native mode must be boolean") + self.native = native + self.reference = VersionedReference(ref="native_tools_v1" if native else "code_messages_v1", version=1) + + def convert(self, resources, profile, *, stream=False, request_id=None, purpose="agent", run_id=None): + # Detach mutable collections before sorting or producing a request. + resources = _RuntimeResourceView.model_validate(resources.model_dump()) + validate_instruction_mapping(profile.instruction_mapping) + native = self.native and profile.capabilities.tools.status == "supported" + priorities = {name: index for index, name in enumerate(profile.instruction_mapping.ordering)} + instructions = sorted(resources.instructions, key=lambda item: ( + priorities.get("platform_instruction" if item.message.semantic_role == "platform_instruction" + else item.source, len(priorities)), item.order, + )) + instruction_ids = {item.message.message_id for item in instructions} + remainder = sorted((item for item in resources.items() if item.message.message_id not in instruction_ids), + key=lambda item: item.order) + messages = [item.message.model_copy(update={"source": item.source}) for item in instructions + remainder] + if native: + # Code histories can contain observations without provider-native call IDs. + messages = [_code_message(message) if message.semantic_role in {"tool_result", "tool"} + and message.tool_call_id is None else message for message in messages] + else: + messages = [_code_message(message) for message in messages] + if resources.tool_definitions: + description = ModelMessage( + semantic_role="reference_data", source="authorized_tool_definitions", + content_parts=[_text({"available_tools": [tool.model_dump() for tool in resources.tool_definitions]})], + ) + # Keep the current input in its original position relative to other resources. + index = next(index for index, message in enumerate(messages) + if message.message_id == resources.current_user_input.message.message_id) + messages.insert(index, description) + return ConvertedModelRequest( + request=ModelRequest( + request_id=request_id if request_id is not None else str(uuid4()), outlet_id=profile.outlet_id, + config_revision=profile.config_revision, messages=messages, + tools=resources.tool_definitions if native else [], stream=stream, purpose=purpose, run_id=run_id, + ), + tool_protocol="native" if native else "text_block", + ) + + +class ResourceConverterRegistry: + def __init__(self): + self._converters = tuple(ResourceRequestConverter(native=native) for native in (False, True)) + + def references(self, mode): + if mode not in {"code_converter", "native_converter"}: + raise ValueError("Unknown converter mode") + return tuple(converter.reference for converter in self._converters + if converter.native == (mode == "native_converter")) + + def resolve(self, reference): + for converter in self._converters: + if converter.reference == reference: + return converter + raise ValueError("MODEL_CONVERTER_UNREGISTERED") diff --git a/sdk/nexent/core/model_service/service.py b/sdk/nexent/core/model_service/service.py new file mode 100644 index 0000000000..79eb765301 --- /dev/null +++ b/sdk/nexent/core/model_service/service.py @@ -0,0 +1,547 @@ +"""Logical model calls over registered adapters and the shared HTTP sender.""" + +import asyncio +import inspect +import json +import logging +import math +import random +import time +import uuid +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass, replace + +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.concurrency import ModelConcurrencyExceeded, model_concurrency_limiter +from nexent.core.model_service.contracts import ModelError, ModelEvent, ModelRequest +from nexent.core.model_service.errors import normalize_model_error +from nexent.core.model_service.http_contracts import ModelTransportError +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.registry import AdapterRegistry +from nexent.monitor import get_monitoring_manager + + +logger = logging.getLogger(__name__) + + +class ModelServiceError(Exception): + """Only the public normalized error is exposed to callers.""" + + def __init__(self, error): + self.error = error + super().__init__(error.category) + + +@dataclass(frozen=True) +class ModelRequestRetryPolicy: + max_attempts: int = 3 + + def __post_init__(self): + if type(self.max_attempts) is not int or self.max_attempts < 1: + raise ValueError("Retry attempts must be a positive integer") + + def calculate_backoff(self, attempt: int) -> float: + return min(2 ** min(attempt - 1, 5), 30) + random.uniform(0, 0.25) + + +@dataclass +class _Call: + request: ModelRequest + profile: ResolvedModelProfile + adapter: object + sender: object + prepared: object + scope: RunCancellationScope + deadline: float | None + policy: object + attempt: int = 1 + visible: bool = False + sequence: int = 0 + monitoring: object = None + token_tracker: object = None + request_rebuild: object = None + rebuilds: int = 0 + rebuild_pending: bool = False + + def check(self): + if self.scope.cancelled: + raise RunTerminated() + if self.deadline is not None and time.monotonic() >= self.deadline: + error = normalize_model_error( + ModelTransportError("HTTP_RUN_DEADLINE"), + request_id=self.request.request_id, + attempt=self.attempt, + max_attempts=self.policy.max_attempts, + ) + raise ModelServiceError(error) + + def timeout(self): + self.check() + configured = self.profile.connection.timeout_seconds + return configured if self.deadline is None else min(configured, self.deadline - time.monotonic()) + + def wire_request(self): + self.monitoring.add_span_event("model_attempt_begin", { + "request_id": self.request.request_id, "attempt": self.attempt, + }) + return replace( + self.prepared, + headers=dict(self.prepared.headers), + json_body=json.loads(self.prepared.body_bytes), + timeout_seconds=self.timeout(), + ) + + def event(self, event): + self.check() + if event.type in {"text_delta", "reasoning_delta", "tool_call_delta"}: + self.token_tracker.record_first_token() + elif event.type == "usage": + self.monitoring.add_span_event("model_usage", { + "attempt": self.attempt, **event.usage.model_dump(exclude_none=True), + }) + elif event.type == "message_completed": + self.observe_completion(event.message) + self.sequence += 1 + self.visible |= event.type in {"text_delta", "reasoning_delta", "tool_call_delta", "message_completed"} + return event.model_copy(update={"sequence": self.sequence, "attempt": self.attempt}) + + def observe_completion(self, message): + usage = message.usage + if usage.input_tokens is not None and usage.output_tokens is not None: + self.token_tracker.record_completion(usage.input_tokens, usage.output_tokens, total_tokens=usage.total_tokens) + self.monitoring.set_span_attributes(**{ + "llm.usage." + key: value for key, value in usage.model_dump().items() if value is not None + }, **{"llm.response.finish_reason": message.finish_reason, "llm.response.attempt": self.attempt}) + self.monitoring.set_openinference_output("".join( + part.get("text", "") for part in message.content_parts if part.get("type") == "text" + )) + + def failure(self, error): + if self.scope.cancelled: + raise RunTerminated() + normalized = ( + error.error + if isinstance(error, ModelServiceError) + else self.adapter.normalize_error( + error, + request_id=self.request.request_id, + attempt=self.attempt, + max_attempts=self.policy.max_attempts, + ) + ) + self.rebuild_pending = ( + normalized.category == "MODEL_CONTEXT_EXCEEDED" and self.request_rebuild is not None + and not self.visible and self.attempt < self.policy.max_attempts and self.rebuilds < 2 + ) + if self.rebuild_pending: + normalized = normalized.model_copy(update={"retryable": True}) + delay = None + if normalized.retryable and not self.visible and self.attempt < self.policy.max_attempts: + delay = 0 if self.rebuild_pending else self.policy.calculate_backoff(self.attempt) + if not isinstance(delay, (int, float)) or not math.isfinite(delay) or delay < 0: + raise ValueError("Retry backoff must be finite and nonnegative") + response = getattr(error, "response", None) + if response is not None: + try: + retry_after = float( + next(value for key, value in response.headers.items() if key.lower() == "retry-after") + ) + if math.isfinite(retry_after) and retry_after >= 0: + delay = max(delay, retry_after) + except (ValueError, TypeError, StopIteration): + pass + if self.deadline is not None and time.monotonic() + delay >= self.deadline: + delay = None + normalized = normalized.model_copy(update={"will_retry": delay is not None}) + self.monitoring.add_span_event("model_attempt_error", { + "request_id": self.request.request_id, "attempt": self.attempt, + "category": normalized.category, "will_retry": delay is not None, + }) + if normalized.category in {"MODEL_TIMEOUT", "MODEL_CONCURRENCY_TIMEOUT", "MODEL_RUN_DEADLINE"}: + logger.warning( + "model_request_timeout", + extra={ + "request_id": self.request.request_id, + "outlet_id": self.profile.outlet_id, + "attempt": self.attempt, + "category": normalized.category, + "phase": "after_output" if self.visible else "before_output", + "timeout_seconds": self.profile.connection.timeout_seconds, + "exception_type": type(error).__name__, + }, + ) + if delay is not None: + logger.info( + "model_request_retry", + extra={ + "request_id": self.request.request_id, + "attempt": self.attempt, + "category": normalized.category, + "will_retry": True, + "retry_delay_seconds": delay, + }, + ) + return normalized, delay + + def apply_rebuild(self, request): + self.check() + if not isinstance(request, ModelRequest): + raise TypeError("request_rebuild must return a ModelRequest") + request = ModelRequest.model_validate(request.model_dump()) + if request.model_dump(exclude={"messages"}) != self.request.model_dump(exclude={"messages"}): + raise ValueError("Context recovery may only replace messages") + prepared = self.adapter.prepare_http_request(request, self.profile) + self.request, self.prepared = request, prepared + self.rebuilds += 1 + self.rebuild_pending = False + self.monitoring.add_span_event("model_context_rebuild", { + "request_id": request.request_id, "attempt": self.attempt, + "recovery_dispatch": self.rebuilds, "message_count": len(request.messages), + }) + self.check() + + def wait(self, delay): + end = time.monotonic() + delay + while time.monotonic() < end: + self.check() + self.scope.stop_event.wait(min(0.05, end - time.monotonic())) + self.check() + self.attempt += 1 + + async def await_retry(self, delay): + end = time.monotonic() + delay + while time.monotonic() < end: + self.check() + await asyncio.sleep(min(0.05, end - time.monotonic())) + self.check() + self.attempt += 1 + + +class ModelService: + """No environment reads, resource execution, AgentMode selection or semantic repair.""" + + def __init__(self, sender_factory, *, registry=None, limiter=None, retry_policy=None, monitoring=None, outlet_reader=None): + if outlet_reader is not None and ( + not callable(outlet_reader) or inspect.iscoroutinefunction(outlet_reader) + or inspect.iscoroutinefunction(getattr(outlet_reader, "__call__", None)) + ): + raise TypeError("Outlet reader must be a synchronous snapshot reader") + self._outlet_reader = outlet_reader + self._sender_factory = sender_factory + self._registry = registry if registry is not None else AdapterRegistry() + self._limiter = limiter if limiter is not None else model_concurrency_limiter + self._policy = retry_policy if retry_policy is not None else ModelRequestRetryPolicy() + self._monitoring = monitoring if monitoring is not None else get_monitoring_manager() + if type(self._policy.max_attempts) is not int or self._policy.max_attempts < 1: + raise ValueError("Retry attempts must be a positive integer") + + def bind_outlet(self, model_id, run_context): + """Select one tenant-owned saved snapshot without storing active run state on the service.""" + tenant_id = getattr(run_context, "tenant_id", None) + if (type(model_id) is not int or not 0 < model_id < 2**31 + or not isinstance(tenant_id, str) or not tenant_id.strip()): + raise ValueError("MODEL_OUTLET_SELECTION_INVALID") + if self._outlet_reader is None: + raise ValueError("MODEL_OUTLET_READER_UNCONFIGURED") + try: + profile = self._outlet_reader(model_id, run_context) + except Exception: + raise ValueError("MODEL_OUTLET_RESOLUTION_FAILED") from None + if profile is None: + raise ValueError("MODEL_OUTLET_NOT_FOUND") + try: + if not isinstance(profile, ResolvedModelProfile): + raise TypeError("Invalid snapshot") + profile = ResolvedModelProfile.model_validate(profile.model_dump()) + except Exception: + raise ValueError("MODEL_OUTLET_SNAPSHOT_INVALID") from None + if profile.outlet_id != model_id or profile.identity.tenant_id != tenant_id: + raise ValueError("MODEL_OUTLET_SNAPSHOT_MISMATCH") + self._registry.resolve(profile.bindings.adapter) + return profile + + def _call(self, request, profile, scope, deadline, request_rebuild=None): + if deadline is not None and (type(deadline) not in (int, float) or not math.isfinite(deadline)): + raise ValueError("Run deadline must be a finite monotonic timestamp") + request = ModelRequest.model_validate(request.model_dump()) + profile = ResolvedModelProfile.model_validate(profile.model_dump()) + scope = scope if scope is not None else RunCancellationScope() + if scope.cancelled: + raise RunTerminated() + try: + adapter = self._registry.resolve(profile.bindings.adapter) + prepared = adapter.prepare_http_request(request, profile) + sender = self._sender_factory(profile).freeze_credentials(prepared) + except Exception as error: + raise ModelServiceError( + normalize_model_error( + error, + request_id=request.request_id, + max_attempts=self._policy.max_attempts, + ) + ) from None + call = _Call(request, profile, adapter, sender, prepared, scope, deadline, self._policy, + monitoring=self._monitoring, request_rebuild=request_rebuild) + call.check() + return call + + @staticmethod + def _validate_rebuild(callback, *, asynchronous=False): + if callback is None: + return + is_async = inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction(getattr(callback, "__call__", None)) + if not callable(callback) or is_async != asynchronous: + raise TypeError("Request rebuild callback must match the synchronous or asynchronous API") + + @staticmethod + async def _arebuild(call, error): + call.check() + budget = asyncio.timeout_at(call.deadline) + loop, task = asyncio.get_running_loop(), asyncio.current_task() + active = True + + def cancel_if_active(): + if active: + task.cancel() + + token = call.scope.register_closer(lambda: loop.call_soon_threadsafe(cancel_if_active)) + try: + async with budget: + request = await call.request_rebuild(error) + except asyncio.CancelledError: + call.check() + raise + except TimeoutError: + if budget.expired(): + call.check() + raise + finally: + active = False + call.scope.unregister_closer(token) + call.apply_rebuild(request) + + @contextmanager + def _trace(self, call): + with self._monitoring.trace_llm_request( + f"{call.profile.identity.display_name or call.profile.identity.request_model_id}.generate", + call.profile.identity.request_model_id, + **{ + "input.value": [message.model_dump(mode="json") for message in call.request.messages], + "model.request_id": call.request.request_id, + **({"model.outlet_id": call.profile.outlet_id} if call.profile.outlet_id is not None else {}), + "model.config_revision": call.profile.config_revision, + "model.provider": call.profile.identity.provider_id, + }, + ) as span: + call.token_tracker = self._monitoring.create_token_tracker(call.profile.identity.request_model_id, span) + yield + + def _admission_args(self, call): + profile = call.profile + return { + "key": (profile.identity.tenant_id, profile.identity.provider_id, profile.identity.request_model_id), + "limit": profile.connection.concurrency_limit, + "timeout_seconds": call.timeout(), + "stop_event": call.scope.stop_event, + } + + @contextmanager + def _permit(self, call): + call.check() + permit = None + try: + if call.profile.connection.concurrency_limit is not None: + try: + permit = self._limiter.acquire(**self._admission_args(call)) + except ModelConcurrencyExceeded: + raise ModelTransportError("HTTP_CONCURRENCY_TIMEOUT") from None + call.check() + yield + finally: + if permit is not None: + permit.release() + + @asynccontextmanager + async def _apermit(self, call): + call.check() + permit = None + try: + if call.profile.connection.concurrency_limit is not None: + try: + permit = await self._limiter.acquire_async(**self._admission_args(call)) + except ModelConcurrencyExceeded: + raise ModelTransportError("HTTP_CONCURRENCY_TIMEOUT") from None + call.check() + budget = asyncio.timeout_at(call.deadline) + try: + async with budget: + yield + except TimeoutError: + if budget.expired(): + raise ModelTransportError("HTTP_RUN_DEADLINE") from None + raise + finally: + if permit is not None: + permit.release() + + def invoke(self, request, profile, *, scope=None, deadline=None, request_rebuild=None): + self._validate_rebuild(request_rebuild, asynchronous=False) + if request.stream: + raise ValueError("Invoke requires a non-streaming request") + call = self._call(request, profile, scope, deadline, request_rebuild) + with self._trace(call): + while True: + try: + with self._permit(call): + raw = call.sender.invoke(call.wire_request(), scope=call.scope) + result = call.adapter.decode_response(raw, call.request, call.profile) + call.check() + call.observe_completion(result) + return result + except Exception as error: + normalized, delay = call.failure(error) + if delay is None: + raise ModelServiceError(normalized) from None + if call.rebuild_pending: + call.check() + call.apply_rebuild(call.request_rebuild(normalized)) + call.wait(delay) + + async def ainvoke(self, request, profile, *, scope=None, deadline=None, request_rebuild=None): + self._validate_rebuild(request_rebuild, asynchronous=True) + if request.stream: + raise ValueError("Invoke requires a non-streaming request") + call = self._call(request, profile, scope, deadline, request_rebuild) + with self._trace(call): + while True: + try: + async with self._apermit(call): + raw = await call.sender.ainvoke(call.wire_request(), scope=call.scope) + result = call.adapter.decode_response(raw, call.request, call.profile) + call.check() + call.observe_completion(result) + return result + except Exception as error: + normalized, delay = call.failure(error) + if delay is None: + raise ModelServiceError(normalized) from None + if call.rebuild_pending: + call.check() + await self._arebuild(call, normalized) + await call.await_retry(delay) + + @staticmethod + def _error_event(call, error, message_id): + call.sequence += 1 + return ModelEvent( + request_id=call.request.request_id, + message_id=message_id, + sequence=call.sequence, + attempt=call.attempt, + type="error", + error=error, + ) + + @staticmethod + def _check_empty_response(call, event, policy): + if policy == "accept" or event.type != "message_completed": + return + message = event.message + if message.tool_calls or message.refusal or any( + part.get("type") == "text" and part.get("text", "").strip() for part in message.content_parts + ): + return + raise ModelServiceError(ModelError( + category="MODEL_EMPTY_RESPONSE", scope="protocol", + retryable=policy == "retry" and message.finish_reason in (None, "stop"), + attempt=call.attempt, max_attempts=call.policy.max_attempts, + safe_message="The model returned an empty response.", request_id=call.request.request_id, + finish_reason=message.finish_reason, + )) + + @staticmethod + def _validate_empty_policy(policy): + if policy not in ("accept", "fail", "retry"): + raise ValueError("Unknown empty response policy") + + def _events(self, call, empty_response_policy): + message_id = uuid.uuid4() + pending_delay = None + while True: + try: + if pending_delay is not None: + call.wait(pending_delay) + with self._permit(call), call.sender.stream(call.wire_request(), scope=call.scope) as frames: + for event in call.adapter.decode_stream(frames, call.request, call.profile): + message_id = event.message_id + self._check_empty_response(call, event, empty_response_policy) + yield call.event(event) + if event.type == "message_completed": + return + return + except Exception as error: + normalized, delay = call.failure(error) + if delay is None: + yield self._error_event(call, normalized, message_id) + return + if call.rebuild_pending: + call.check() + call.apply_rebuild(call.request_rebuild(normalized)) + pending_delay = delay + + async def _aevents(self, call, empty_response_policy): + message_id = uuid.uuid4() + pending_delay = None + while True: + try: + if pending_delay is not None: + await call.await_retry(pending_delay) + async with self._apermit(call), call.sender.astream(call.wire_request(), scope=call.scope) as frames: + async for event in call.adapter.adecode_stream(frames, call.request, call.profile): + message_id = event.message_id + self._check_empty_response(call, event, empty_response_policy) + yield call.event(event) + if event.type == "message_completed": + return + return + except Exception as error: + normalized, delay = call.failure(error) + if delay is None: + yield self._error_event(call, normalized, message_id) + return + if call.rebuild_pending: + call.check() + await self._arebuild(call, normalized) + pending_delay = delay + + @contextmanager + def stream( + self, request, profile, *, scope=None, deadline=None, empty_response_policy="accept", request_rebuild=None, + ): + self._validate_rebuild(request_rebuild, asynchronous=False) + self._validate_empty_policy(empty_response_policy) + if not request.stream: + raise ValueError("Stream requires a streaming request") + call = self._call(request, profile, scope, deadline, request_rebuild) + events = self._events(call, empty_response_policy) + with self._trace(call): + try: + yield events + finally: + events.close() + + @asynccontextmanager + async def astream( + self, request, profile, *, scope=None, deadline=None, empty_response_policy="accept", request_rebuild=None, + ): + self._validate_rebuild(request_rebuild, asynchronous=True) + self._validate_empty_policy(empty_response_policy) + if not request.stream: + raise ValueError("Stream requires a streaming request") + call = self._call(request, profile, scope, deadline, request_rebuild) + events = self._aevents(call, empty_response_policy) + with self._trace(call): + try: + yield events + finally: + await events.aclose() diff --git a/sdk/nexent/core/model_service/sse.py b/sdk/nexent/core/model_service/sse.py new file mode 100644 index 0000000000..dc0c2454a2 --- /dev/null +++ b/sdk/nexent/core/model_service/sse.py @@ -0,0 +1,138 @@ +"""Bounded incremental SSE framing shared by sync and async HTTP senders.""" + +import codecs +from collections.abc import Iterator +from dataclasses import dataclass + + +class SSEDecodeError(ValueError): + """Safe framing failure with no provider payload in diagnostics.""" + + def __init__(self, category: str): + self.category = category + super().__init__(category) + + +@dataclass(frozen=True) +class SSEEvent: + """A complete SSE frame; provider JSON interpretation belongs to adapters.""" + + data: str + event: str = "message" + event_id: str = "" + + +class SSEDecoder: + """Decode UTF-8 text fragments without reconnecting or interpreting retry fields. + + Only blank-line-terminated data blocks become events. Incomplete data at EOF + is an explicit Nexent failure rather than a successful model completion. + """ + + def __init__(self, *, max_event_bytes: int = 1024 * 1024): + if type(max_event_bytes) is not int or max_event_bytes <= 0: + raise ValueError("SSE event byte limit must be a positive integer") + self._limit = max_event_bytes + self._size = 0 + self._line: list[str] = [] + self._data: list[str] = [] + self._event = "" + self._id = "" + self._first = True + self._after_cr = False + self._closed = False + + def _fail(self, category: str) -> None: + self._closed = True + self._line.clear() + self._data.clear() + raise SSEDecodeError(category) from None + + def _consume_line(self) -> SSEEvent | None: + line = "".join(self._line) + self._line.clear() + if not line: + result = SSEEvent("\n".join(self._data), self._event or "message", self._id) if self._data else None + self._data.clear() + self._event = "" + self._size = 0 + return result + if line.startswith(":"): + return None + field, separator, value = line.partition(":") + if separator and value.startswith(" "): + value = value[1:] + if field == "data": + self._data.append(value) + elif field == "event": + self._event = value + elif field == "id" and "\x00" not in value: + self._id = value + return None + + def feed(self, fragment: str) -> Iterator[SSEEvent]: + """Yield completed frames immediately, even before a later frame fails. + + Exhaust this iterator before feeding another fragment. HTTP iteration + supplies already incrementally decoded UTF-8 text, never raw bytes. + """ + if self._closed: + raise SSEDecodeError("SSE_DECODER_CLOSED") + if not isinstance(fragment, str): + self._fail("SSE_INVALID_TEXT") + for character in fragment: + if self._first: + self._first = False + if character == "\ufeff": + continue + if self._after_cr: + self._after_cr = False + if character == "\n": + continue + try: + self._size += len(character.encode("utf-8")) + except UnicodeEncodeError: + self._fail("SSE_INVALID_TEXT") + if self._size > self._limit: + self._fail("SSE_EVENT_TOO_LARGE") + if character in ("\r", "\n"): + self._after_cr = character == "\r" + event = self._consume_line() + if event is not None: + yield event + else: + self._line.append(character) + + def finish(self) -> None: + """Close framing; provider finish markers still require adapter validation.""" + if self._closed: + raise SSEDecodeError("SSE_DECODER_CLOSED") + self._closed = True + if self._line: + self._consume_line() + if self._data: + self._fail("SSE_INCOMPLETE_EVENT") + + +class SSEByteDecoder: + """Strict UTF-8 decoding keeps byte splits transparent and corruption explicit.""" + + def __init__(self, *, max_event_bytes: int = 1024 * 1024): + self._text = codecs.getincrementaldecoder("utf-8")() + self._frames = SSEDecoder(max_event_bytes=max_event_bytes) + + def feed(self, fragment: bytes) -> Iterator[SSEEvent]: + if not isinstance(fragment, bytes): + self._frames._fail("SSE_INVALID_TEXT") + try: + text = self._text.decode(fragment) + except UnicodeError: + self._frames._fail("SSE_INVALID_TEXT") + yield from self._frames.feed(text) + + def finish(self) -> None: + try: + self._text.decode(b"", final=True) + except UnicodeError: + self._frames._fail("SSE_INVALID_TEXT") + self._frames.finish() diff --git a/sdk/nexent/core/models/capacity_budget.py b/sdk/nexent/core/models/capacity_budget.py index 8378c368a9..50074b110c 100644 --- a/sdk/nexent/core/models/capacity_budget.py +++ b/sdk/nexent/core/models/capacity_budget.py @@ -540,3 +540,34 @@ def _uncertainty_reserve( reserve = math.ceil(capacity_snapshot.context_window_tokens * 0.10) return reserve, "context_window_10pct", ["uncertainty_reserve_active"] + + +def verify_context_budget_capacity( + *, budget_snapshot: ContextBudgetSnapshot, capacity_snapshot: Optional[Mapping[str, Any]], +) -> None: + """Reuse the legacy dispatch identity check without provider or HTTP dependencies.""" + if not capacity_snapshot: + return + w1_fingerprint = capacity_snapshot.get("capacity_fingerprint") + provider = capacity_snapshot.get("provider") + model_name = capacity_snapshot.get("model_name") + if not w1_fingerprint and not provider and not model_name: + return + if w1_fingerprint and w1_fingerprint != budget_snapshot.w1_fingerprint: + raise ContextBudgetCapacityMismatch( + field="w1_fingerprint", + expected=w1_fingerprint, + actual=budget_snapshot.w1_fingerprint, + ) + if provider and provider != budget_snapshot.provider: + raise ContextBudgetCapacityMismatch( + field="provider", + expected=provider, + actual=budget_snapshot.provider, + ) + if model_name and model_name != budget_snapshot.model_name: + raise ContextBudgetCapacityMismatch( + field="model_name", + expected=model_name, + actual=budget_snapshot.model_name, + ) diff --git a/sdk/nexent/core/tools/parallel_executor.py b/sdk/nexent/core/tools/parallel_executor.py index 14bc9f6398..f6e32f5b1d 100644 --- a/sdk/nexent/core/tools/parallel_executor.py +++ b/sdk/nexent/core/tools/parallel_executor.py @@ -1,6 +1,6 @@ +import threading from concurrent.futures import TimeoutError as FutureTimeoutError from copy import deepcopy -import threading from typing import Any, Dict from smolagents.tools import Tool @@ -74,7 +74,7 @@ def inputs_for_timeout(cls, default_timeout_seconds: int) -> dict: inputs["timeout"]["description_zh"] = f"单个任务超时秒数(默认{default_timeout_seconds})" return inputs - def __init__(self, default_timeout_seconds: int = 120): + def __init__(self, default_timeout_seconds: int = 120, *, execution_lane: str = "model-tool-io"): if ( isinstance(default_timeout_seconds, bool) or not isinstance(default_timeout_seconds, int) @@ -83,6 +83,7 @@ def __init__(self, default_timeout_seconds: int = 120): raise ValueError("default_timeout_seconds must be a positive integer") super().__init__() self.default_timeout_seconds = default_timeout_seconds + self.execution_lane = execution_lane self.inputs = self.inputs_for_timeout(default_timeout_seconds) self.description_zh = self.description_zh.replace("默认120秒", f"默认{default_timeout_seconds}秒") @@ -96,7 +97,9 @@ def forward(self, tasks, timeout: int | None = None, max_workers: int = 4): Returns a list (all 2-tuples) or dict (all 3-tuples). """ effective_timeout = self.default_timeout_seconds if timeout is None else timeout - return _parallel_executor(tasks, timeout=effective_timeout, max_workers=max_workers) + if self.execution_lane == "model-tool-io": + return _parallel_executor(tasks, timeout=effective_timeout, max_workers=max_workers) + return _parallel_executor(tasks, timeout=effective_timeout, max_workers=max_workers, lane=self.execution_lane) # --------------------------------------------------------------------------- @@ -130,7 +133,7 @@ def _validate_tasks(tasks): return names, has_names -def _execute_tasks(tasks, names, timeout, max_workers): +def _execute_tasks(tasks, names, timeout, max_workers, *, lane="model-tool-io"): """Submit tasks to a thread pool and collect results.""" n = len(tasks) results = [None] * n @@ -161,7 +164,7 @@ def run_admitted(func, kwargs): results[idx] = f"[{label}] Not callable: {type(func).__name__}" continue execution = manager.submit( - "model-tool-io", + lane, ManagedTaskSpec( task_name="parallel-tool-call", owner="sdk-agent", @@ -190,11 +193,14 @@ def run_admitted(func, kwargs): return results -def _parallel_executor(tasks, timeout: int = 120, max_workers: int = 4): +def _parallel_executor(tasks, timeout: int = 120, max_workers: int = 4, *, lane="model-tool-io"): names, has_names = _validate_tasks(tasks) if not tasks: return [] - results = _execute_tasks(tasks, names, timeout, max_workers) + if lane == "model-tool-io": + results = _execute_tasks(tasks, names, timeout, max_workers) + else: + results = _execute_tasks(tasks, names, timeout, max_workers, lane=lane) if has_names: return {names[idx]: results[idx] for idx in range(len(tasks))} return results diff --git a/sdk/nexent/core/utils/observer.py b/sdk/nexent/core/utils/observer.py index 9dabda4827..50b3d05b8c 100644 --- a/sdk/nexent/core/utils/observer.py +++ b/sdk/nexent/core/utils/observer.py @@ -42,6 +42,11 @@ class ProcessType(Enum): MODEL_OUTPUT_CODE = "model_output_code" # model streaming output, code content MODEL_ATTEMPT_CONTROL = "model_attempt_control" # hidden begin/rollback/commit boundary + MODEL_OUTPUT = "model_output" # Ordered normal body from a native runtime + TOOL_RESULT = "tool_result" # Exact resource observation, including errors + STEP_OBSERVATION = "step_observation" # Public model-step metrics + RUN_TERMINAL = "run_terminal" # Explicit public Agent result + STEP_COUNT = "step_count" # current step of agent PARSE = "parse" # code parsing result EXECUTION_LOGS = "execution_logs" # code execution result @@ -716,9 +721,13 @@ def add_message(self, agent_name, process_type, content, **kwargs): agent_name=kwargs.get("agent_name"), explicit_agent_id=explicit_agent_id, metadata={ - key: kwargs[key] - for key in ("error_code", "retryable") - if key in kwargs + **{key: kwargs[key] for key in ("error_code", "retryable") if key in kwargs}, + **{key: value for key, value in (kwargs.get("runtime_envelope") or {}).items() + if key in {"schema_version", "runtime_mode", "agent_core", "turn_id", "block_id", "sequence", + "phase", "local_call_id", "provider_call_id", "tool_call_id", "tool_name", + "tool_arguments", "raw_arguments", "result", "is_error", "usage", "finish_reason", + "step_number", "duration", "step_error", "status", "end_reason", "final_content", + "final_block_ids", "error", "context_capacity"}} }, ) @@ -765,6 +774,8 @@ def add_subagent_start(self, agent_id, agent_name, task=None, "task": task if task is not None else "", "invocation_id": invocation_id, } + if stack: + payload_data["parent_invocation_id"] = stack[-1][0] if invocation_name is not None: payload_data["invocation_name"] = invocation_name payload_data.update({key: value for key, value in { diff --git a/sdk/nexent/monitor/monitoring.py b/sdk/nexent/monitor/monitoring.py index 647fe16a80..25b1edf69a 100644 --- a/sdk/nexent/monitor/monitoring.py +++ b/sdk/nexent/monitor/monitoring.py @@ -1957,8 +1957,13 @@ def record_token(self, token: str) -> None: "token_length": len(token) }) - def record_completion(self, input_tokens: int = 0, output_tokens: int = 0) -> None: - """Record completion metrics using OpenInference semantic conventions.""" + def record_completion( + self, input_tokens: int = 0, output_tokens: int = 0, *, total_tokens: Optional[int] = None, + ) -> None: + """Record completion metrics, preserving a supplied provider total.""" + if total_tokens is not None and (type(total_tokens) is not int or total_tokens < 0): + raise ValueError("Reported total tokens must be a non-negative integer") + reported_total = input_tokens + output_tokens if total_tokens is None else total_tokens if not self.manager.is_enabled: return @@ -1985,12 +1990,12 @@ def record_completion(self, input_tokens: int = 0, output_tokens: int = 0) -> No usage_details = { "input": input_tokens, "output": output_tokens, - "total": input_tokens + output_tokens, + "total": reported_total, } self.span.set_attributes({ "llm.token_count.prompt": input_tokens, "llm.token_count.completion": output_tokens, - "llm.token_count.total": input_tokens + output_tokens, + "llm.token_count.total": reported_total, "llm.usage_details": json.dumps( usage_details, ensure_ascii=False), "llm.generation_rate": generation_rate, diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 7dc488c9a4..a28d4653c5 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -76,6 +76,7 @@ override-dependencies = [ [project.optional-dependencies] jiuwen = [ "openjiuwen==0.1.19", + "jsonschema>=4.18,<5", ] quality = [ "ruff>=0.9.0", @@ -106,6 +107,7 @@ exclude = ["tests*", "examples*"] namespaces = true [tool.setuptools.package-data] +"nexent.core.model_service.profiles" = ["**/*.toml", "LICENSE", "README.md"] "nexent.core.prompts" = ["zh/*/*.yaml", "en/*/*.yaml"] "nexent.assets" = ["*.png", "*.jpg", "*.gif", "*.webp"] diff --git a/test-e2e/cases/AGENT-CORE-UI-D4-001/case.yaml b/test-e2e/cases/AGENT-CORE-UI-D4-001/case.yaml new file mode 100644 index 0000000000..85fce95f14 --- /dev/null +++ b/test-e2e/cases/AGENT-CORE-UI-D4-001/case.yaml @@ -0,0 +1,84 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: AGENT-CORE-UI-D4-001 + feature_id: AGENT-CORE-CONFIG + requirement_ids: + - AGENT-CORE-UI-20261013 + - JIUWEN-M3-20261015 + - JIUWEN-M4-20261016 + business_rule_ids: + - AGENT-CORE-CONFIG-RULE-001 + - AGENT-CORE-CONFIG-RULE-002 + title: Select Jiuwen and persist a compatible agent draft + type: E2E + priority: P1 + status: active + automation: automated + objective: Core changes preserve all resource and policy values across save, reload, + rollback and return to smolagents. + preconditions: + - A running frontend is reachable; deterministic existing HTTP fixtures isolate + Agent data, authorization and model/resource boundaries. + test_data: + profile: mock + locale: zh + viewport: + width: 1920 + height: 1080 + steps: + - order: 1 + action: Open an editable draft with smolagents and select Jiuwen. + expected: Jiuwen save changes only Core; all policy values and resource bindings + are preserved. + - order: 2 + action: Reload, inspect disabled controls and preserved values, then select smolagents + and inspect restored editability. + expected: Reload preserves Jiuwen and saved values; unsupported controls are disabled; + switching back restores editability and original bindings. Jiuwen collaboration + selection and discovery remain editable. The saved CM switch is editable under + Jiuwen. + - order: 3 + action: Reject the next Core save and retry selection. + expected: Failure restores saved Core and displays error; retry persists Jiuwen. + - order: 4 + action: Open the same configuration with read-only permissions. + expected: Core cannot be changed and no mutation or run request occurs. Collaboration + selection and discovery remain disabled. + expected_results: &id001 + - Jiuwen save changes only Core; all policy values and resource bindings are preserved. + - Reload preserves Jiuwen and saved values; unsupported controls are disabled; switching + back restores editability and original bindings. Jiuwen collaboration selection + and discovery remain editable. The saved CM switch is editable under Jiuwen. + - Failure restores saved Core and displays error; retry persists Jiuwen. + - Core cannot be changed and no mutation or run request occurs. Collaboration selection + and discovery remain disabled. + forbidden_side_effects: + - No unrelated writes, agent run, loss of resource/policy values or read-only mutations. + actor: Authorized Agent development user + journey: + - Open an editable draft with smolagents and select Jiuwen. + - Reload, inspect disabled controls and preserved values, then select smolagents + and inspect restored editability. + - Reject the next Core save and retry selection. + - Open the same configuration with read-only permissions. + final_business_result: *id001 + failure_evidence: + - Fixed Playwright journey assertions, sanitized request payloads and terminal audit. + acceptance: + - id: AC-01 + description: Core changes preserve all resource and policy values across save, + reload, rollback and return to smolagents. + steps: + - 1 + - 2 + - 3 + - 4 + expected_results: + - 1 + - 2 + - 3 + - 4 + forbidden_side_effects: + - 1 diff --git a/test-e2e/cases/AGENT-CORE-UI-D4-001/execution.yaml b/test-e2e/cases/AGENT-CORE-UI-D4-001/execution.yaml new file mode 100644 index 0000000000..04dfdab508 --- /dev/null +++ b/test-e2e/cases/AGENT-CORE-UI-D4-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: AGENT-CORE-UI-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: AGENT-CORE-UI-D4-001 +timeout_seconds: 240 +acceptance_bindings: + AC-01: + - AGENT-CORE-UI-D4-001 +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/AGENT-CORE-UI-D4-001/test.spec.ts b/test-e2e/cases/AGENT-CORE-UI-D4-001/test.spec.ts new file mode 100644 index 0000000000..a9436e8b5e --- /dev/null +++ b/test-e2e/cases/AGENT-CORE-UI-D4-001/test.spec.ts @@ -0,0 +1,128 @@ +import { journey } from "../../infra/automation/d4/runner/journey"; +import { baseUrl, installEditor, label } from "../../infra/automation/d4/fixtures/agent-editor-workflows"; + +journey("AGENT-CORE-UI-D4-001", async ({ page, contract, expect }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + const fixture = await installEditor(page); + Object.assign(fixture.draft, { + agent_core: "smolagents", enable_context_manager: true, provide_run_summary: true, + enable_protocol_repair_retry: true, + sub_agent_id_list: [8], sub_agent_relations: [{agent_id:8,version_no:1}], external_sub_agent_id_list:[9], + verification_config:{enabled:true,guardrail_config:{enabled:true,rules:[],default_action:"pass"}}, + }); + const original = JSON.parse(JSON.stringify(fixture.draft)); + let rejectNext = false; + let rejections = 0; + let readonlyWrites = -1; + await page.route("**/api/agent/update", async (route) => { + if (!rejectNext) return route.fallback(); + rejectNext = false; + rejections++; + await route.fulfill({status:400,contentType:"application/json",body:JSON.stringify({detail:"Controlled Core save failure"})}); + }); + const steps=contract.expectedIds("steps"); + await contract.precondition(contract.expectedIds("preconditions")[0], async () => { + expect((await page.request.get(baseUrl)).ok()).toBe(true); + }); + const openSettings = async () => { + await expect(page.getByText(label("agent.core.label"), {exact:true})).toBeAttached(); + if (!(await page.getByText(label("agent.core.label"), {exact:true}).isVisible())) { + await page.getByText(label("agent.highFidelity.advancedTitle"),{exact:true}).click(); + } + }; + const select = async (name:string) => { + await page.locator(".ant-select").filter({has:page.locator("#agent-core")}).click(); + await page.locator(".ant-select-dropdown:visible .ant-select-item-option-content").filter({hasText:new RegExp(`^${name}$`)}).click(); + }; + await contract.step(steps[0], async () => { + await page.goto(`${baseUrl}/zh/agents/42`); + await openSettings(); + await select("Jiuwen"); + await expect.poll(()=>fixture.updates.some(x=>x.agent_core==="jiuwen")).toBe(true); + const update=fixture.updates.find(x=>x.agent_core==="jiuwen")!; + expect(update).toEqual({agent_id:42,agent_core:"jiuwen"}); + expect(fixture.draft.enable_context_manager).toBe(true); + expect(fixture.draft.provide_run_summary).toBe(true); + expect(fixture.draft.verification_config.enabled).toBe(true); + expect(fixture.draft.sub_agent_relations).toEqual(original.sub_agent_relations); + expect(fixture.draft.external_sub_agent_id_list).toEqual(original.external_sub_agent_id_list); + expect(fixture.draft.tools).toEqual(original.tools); + expect(fixture.draft.skills).toEqual(original.skills); + expect(fixture.draft.model_ids).toEqual(original.model_ids); + expect(fixture.draft.duty_prompt).toBe(original.duty_prompt); + expect(fixture.draft.sub_agent_id_list).toEqual(original.sub_agent_id_list); + }); + await contract.step(steps[1], async () => { + await page.reload(); await openSettings(); + await expect(page.getByText(label("agent.core.jiuwenHint"),{exact:true})).toBeVisible(); + await expect(page.getByRole("button",{name:label("agentConfig.layout.addAgent"),exact:true})).toBeEnabled(); + await expect(page.getByRole("button",{name:label("agentConfig.layout.thirdPartyAgent"),exact:true})).toBeEnabled(); + await expect(page.getByRole("switch",{name:label("agent.core.contextManager")})).toBeEnabled(); + await expect(page.getByRole("switch",{name:label("agent.core.contextManager")})).toBeChecked(); + await expect(page.getByRole("checkbox",{name:label("agent.highFidelity.runSummary"),exact:true})).toBeDisabled(); + await expect(page.getByRole("checkbox",{name:label("agent.highFidelity.runSummary"),exact:true})).toBeChecked(); + await expect(page.getByRole("checkbox",{name:label("agent.highFidelity.selfValidate"),exact:true})).toBeDisabled(); + await expect(page.getByRole("checkbox",{name:label("agent.highFidelity.selfValidate"),exact:true})).toBeChecked(); + await page.getByRole("button",{name:label("agent.highFidelity.moreSettings"),exact:true}).click(); + const more=page.getByRole("dialog"); + await expect(more.getByRole("switch").nth(0)).toBeDisabled(); + await expect(more.getByRole("switch").nth(1)).toBeDisabled(); + await page.keyboard.press("Escape"); + await expect(more).not.toBeVisible(); + const header=page.getByTestId("agent-config-header"); + await header.getByRole("button",{name:label("agent.highFidelity.debugMode"),exact:true}).click(); + await page.getByRole("button",{name:label("agent.debug.options"),exact:true}).click(); + await expect(page.getByRole("menuitem",{name:label("chat.composer.planning"),exact:true})).toHaveAttribute("aria-disabled","true"); + await page.keyboard.press("Escape"); + await header.getByRole("button",{name:label("agent.highFidelity.configureMode"),exact:true}).click(); + await openSettings(); + await select("smolagents"); + await expect.poll(()=>fixture.draft.agent_core).toBe("smolagents"); + const context=page.getByRole("switch",{name:label("agent.core.contextManager")}); + await expect(context).toBeEnabled(); await expect(context).toBeChecked(); + await expect.poll(()=>fixture.draft.enable_context_manager).toBe(true); + expect(fixture.draft.sub_agent_id_list).toEqual(original.sub_agent_id_list); + }); + await contract.step(steps[2], async () => { + rejectNext=true; await select("Jiuwen"); + await expect(page.getByText("Controlled Core save failure",{exact:false})).toBeVisible(); + expect(fixture.draft.agent_core).toBe("smolagents"); + await expect(page.getByRole("switch",{name:label("agent.core.contextManager")})).toBeEnabled(); + await select("Jiuwen"); + await expect.poll(()=>fixture.draft.agent_core).toBe("jiuwen"); + }); + await contract.step(steps[3], async () => { + const readonly=await page.context().newPage(); + const readonlyFixture=await installEditor(readonly,"READ_ONLY"); + Object.assign(readonlyFixture.draft,{agent_core:"jiuwen",enable_context_manager:false}); + await readonly.goto(`${baseUrl}/zh/agents/42`); + await readonly.getByText(label("agent.highFidelity.advancedTitle"),{exact:true}).click(); + await expect(readonly.locator("#agent-core")).toBeDisabled(); + await expect(readonly.getByRole("button",{name:label("agentConfig.layout.addAgent"),exact:true})).toBeDisabled(); + await expect(readonly.getByRole("button",{name:label("agentConfig.layout.thirdPartyAgent"),exact:true})).toBeDisabled(); + expect(readonlyFixture.updates).toEqual([]); + expect(readonlyFixture.unexpectedWrites).toEqual([]); + readonlyWrites=readonlyFixture.updates.length+readonlyFixture.unexpectedWrites.length; + await readonly.close(); + }); + const assertions=contract.expectedIds("assertions"); + await contract.assertion(assertions[0],async()=>{ + expect(fixture.unexpectedWrites).toEqual([]); + expect(fixture.creationAttempts).toEqual([]); + expect(fixture.draft.tools).toEqual(original.tools); + expect(fixture.draft.skills).toEqual(original.skills); + }); + await contract.assertion(assertions[1],async()=>{ + expect(fixture.updates.some(x=>x.agent_core==="smolagents")).toBe(true); + expect(fixture.draft.enable_context_manager).toBe(true); + expect(fixture.updates.every(x=>x.enable_context_manager===undefined)).toBe(true); + expect(fixture.draft.sub_agent_id_list).toEqual(original.sub_agent_id_list); + }); + await contract.assertion(assertions[2],async()=>{ + expect(rejections).toBe(1); + expect(fixture.draft.agent_core).toBe("jiuwen"); + }); + await contract.assertion(assertions[3],async()=>{ + expect(readonlyWrites).toBe(0); + }); +}); diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D3-001/case.yaml b/test-e2e/cases/JIUWEN-CONTEXT-D3-001/case.yaml new file mode 100644 index 0000000000..91cbbeaf99 --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D3-001/case.yaml @@ -0,0 +1,71 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-CONTEXT-D3-001 + feature_id: JIUWEN-CONTEXT + requirement_ids: + - JIUWEN-M4-20261016 + business_rule_ids: + - JIUWEN-CONTEXT-RULE-001 + - JIUWEN-CONTEXT-RULE-002 + title: Persisted Core change and saved CM D3 + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Continue persisted conversations after configuration-side Core changes + using the saved CM policy. + preconditions: + - Controlled model HTTP endpoint and installed Jiuwen/smolagents; isolated PostgreSQL + for runtime database proof. + test_data: + profile: mock + steps: + - order: 1 + action: Run and save historical facts with one Core, prepare the effective immutable + configuration for the other Core, and reopen the original conversation. + expected: The configured new Core and version consume authorized database facts + and preserve original messages, attachments and execution units. + - order: 2 + action: Follow up and reload again across both Core directions, native/text protocols, + CM on/off and existing/no checkpoint combinations. + expected: Current input occurs once, the historical tool effect occurs only in + the original run, and CM generates or reuses checkpoints according to the saved + switch. + expected_results: + - The configured new Core and version consume authorized database facts and preserve + original messages, attachments and execution units. + - Current input occurs once, the historical tool effect occurs only in the original + run, and CM generates or reuses checkpoints according to the saved switch. + forbidden_side_effects: + - No historical tool replay, second authoritative memory, checkpoint corruption, + cross-owner history or duplicate current input. + acceptance: + - id: AC-01 + description: Configuration-side Core changes preserve old conversations and saved + CM semantics. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 240 + integration_boundary: + - Real PostgreSQL history/checkpoint DAL, shared ContextManager, Core factory and + real controlled model HTTP. + observations: + - Provider requests, saved checkpoint coverage, tool effect count, selected Core + and cleanup. + cleanup: + - Release native session/tools and managed threads; close loopback listener and + HTTP response. diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D3-001/execution.yaml b/test-e2e/cases/JIUWEN-CONTEXT-D3-001/execution.yaml new file mode 100644 index 0000000000..3c6db147f5 --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D3-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-CONTEXT-D3-001 +implementations: +- framework: pytest + file: test_context.py + selector: test_saved_core_context_continuation +timeout_seconds: 240 +acceptance_bindings: + AC-01: + - test_saved_core_context_continuation +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D3-001/test_context.py b/test-e2e/cases/JIUWEN-CONTEXT-D3-001/test_context.py new file mode 100644 index 0000000000..72822d44b0 --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D3-001/test_context.py @@ -0,0 +1,45 @@ +import asyncio +import sys +from pathlib import Path +import pytest +ROOT=Path(__file__).resolve().parents[3] +sys.path[:0]=[str(ROOT/'sdk'),str(ROOT/'backend'),str(ROOT/'test-e2e/infra/automation')] +from jiuwen_context_runtime import context_database,run_context_scenario +from jiuwen_bridge_runtime import stop_infrastructure + +@pytest.fixture(scope="module",autouse=True) +def cleanup(): + yield + stop_infrastructure() + +pytestmark=[pytest.mark.case_id("JIUWEN-CONTEXT-D3-001"),pytest.mark.stage("D3")] + +@pytest.mark.parametrize("original",["smolagents","jiuwen"]) +@pytest.mark.parametrize("protocol",["native","text"]) +@pytest.mark.parametrize("cm,existing_checkpoint",[(False,False),(True,False),(False,True),(True,True)]) +def test_saved_core_context_continuation(monkeypatch,original,protocol,cm,existing_checkpoint): + with context_database(monkeypatch) as engine: + result=asyncio.run(run_context_scenario(monkeypatch,engine,original,protocol,cm,existing_checkpoint=existing_checkpoint)) + assert result["effects"]==["once"] + assert result["infos"][0].agent_config.agent_core==original + assert result["infos"][1].agent_config.agent_core!=(original) + assert result["infos"][1].agent_config.version_no==2 + assert result["before"] and result["after"] + followups=[body for phase,body in result["calls"] if phase!="seed"] + assert len(followups)==2 + assert "UNTRUSTED_CLIENT_HISTORY" not in str(result["calls"]) + assert str(followups[0]).count("CURRENT_FOLLOWUP_ONLY")==1 + assert str(followups[1]).count("RELOADED_CURRENT_ONLY")==1 + for body in followups: + assert "OLD_FACT_ALPHA" in str(body) + assert "s3://files/old.txt" in str(body) + assert "TOOL_RESULT_ALPHA" in str(body) + if cm: + assert result["summaries"] and result["checkpoints"] + source=str(result["summaries"][0]) + assert "s3://files/old.txt" in source and "TOOL_RESULT_ALPHA" in source + assert all(not body.get("tools") for body in result["summaries"]) + assert all(body.get("max_tokens",0)>0 for body in result["summaries"]) + else: + assert not result["summaries"] + assert len(result["checkpoints"]) == int(existing_checkpoint) diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D4-001/case.yaml b/test-e2e/cases/JIUWEN-CONTEXT-D4-001/case.yaml new file mode 100644 index 0000000000..0373daa96b --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D4-001/case.yaml @@ -0,0 +1,67 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: JIUWEN-CONTEXT-D4-001 + feature_id: JIUWEN-CONTEXT + requirement_ids: + - JIUWEN-M4-20261016 + business_rule_ids: + - JIUWEN-CONTEXT-RULE-001 + - JIUWEN-CONTEXT-RULE-002 + title: Persisted Core change and saved CM D4 + type: E2E + priority: P1 + status: active + automation: automated + objective: Change the configured Core and CM setting, reopen the old chat and continue + in both directions. + preconditions: + - Running built frontend with controlled configuration and conversation HTTP fixtures. + test_data: + profile: mock + locale: zh + viewport: + width: 1920 + height: 1080 + steps: + - order: 1 + action: Open an existing conversation, change the Agent Core to Jiuwen and enable + CM on its configuration page, reload configuration and reopen the conversation. + expected: Old answers and tool cards load; the saved Core and enabled CM survive + reload; loading alone creates no run request. + - order: 2 + action: Submit one follow-up, change the configured Core back to smolagents and + disable CM, then reopen and submit another follow-up. + expected: Both replies appear, both requests retain conversation_id 101 and only + their respective new query, and old records and unrelated configuration remain + intact. + expected_results: &id001 + - Old answers and tool cards load; the saved Core and enabled CM survive reload; + loading alone creates no run request. + - Both replies appear, both requests retain conversation_id 101 and only their respective + new query, and old records and unrelated configuration remain intact. + forbidden_side_effects: + - No historical tool replay, second authoritative memory, checkpoint corruption, + cross-owner history or duplicate current input. + actor: Authorized Agent development user + journey: + - Open an existing conversation, change the Agent Core to Jiuwen and enable CM on + its configuration page, reload configuration and reopen the conversation. + - Submit one follow-up, change the configured Core back to smolagents and disable + CM, then reopen and submit another follow-up. + final_business_result: *id001 + failure_evidence: + - Fixed Playwright journey assertions, sanitized request payloads and terminal audit. + acceptance: + - id: AC-01 + description: Configuration-side Core changes preserve old conversations and saved + CM semantics. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D4-001/execution.yaml b/test-e2e/cases/JIUWEN-CONTEXT-D4-001/execution.yaml new file mode 100644 index 0000000000..084bb9340f --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D4-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-CONTEXT-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: JIUWEN-CONTEXT-D4-001 +timeout_seconds: 240 +acceptance_bindings: + AC-01: + - JIUWEN-CONTEXT-D4-001 +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D4-001/test.spec.ts b/test-e2e/cases/JIUWEN-CONTEXT-D4-001/test.spec.ts new file mode 100644 index 0000000000..9466c29977 --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D4-001/test.spec.ts @@ -0,0 +1,107 @@ +import { journey } from "../../infra/automation/d4/runner/journey"; +import { baseUrl, installEditor, label } from "../../infra/automation/d4/fixtures/agent-editor-workflows"; + +journey("JIUWEN-CONTEXT-D4-001", async ({ page, contract, expect }) => { + const fixture = await installEditor(page); + Object.assign(fixture.draft, { agent_core: "smolagents", enable_context_manager: false }); + const messages: any[] = [ + { role: "user", message_id: 1, message_index: 1, status: "completed", message: "Analyze the original file" }, + { role: "assistant", message_id: 2, message_index: 2, status: "completed", message: [ + { type: "tool", content: JSON.stringify({ tool_name: "historical_tool", tool_arguments: { a: 1 } }), unit_index: 0, tool_call_id: "old-call" }, + { type: "execution_logs", content: "SAVED_TOOL_FACT", unit_index: 1, tool_call_id: "old-call" }, + { type: "final_answer", content: "OLD_CONVERSATION_ALPHA", unit_index: 2 }, + ] }, + ]; + const runs: any[] = []; + await page.route("**/api/**", async (route) => { + const endpoint = new URL(route.request().url()).pathname.replace(/^.*\/api/, ""); + const respond = (data: unknown) => route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(data)}); + if (endpoint === "/memory/config/load") return respond({MEMORY_SWITCH:"N"}); + if (endpoint === "/conversation/list") return respond({code:0,data:{items:[{ + conversation_id:101,conversation_title:"Existing conversation",agent_id:42,create_time:Date.now(),update_time:Date.now(), + }],metadata:{total:1,today:1,last_7_days:0,older:0}}}); + if (endpoint === "/conversation/101") return respond({code:0,data:[{ + conversation_id:101,conversation_title:"Existing conversation",agent_id:42,chat_mode:"execution", + create_time:Date.now(),runtime_metadata:{},runtime_metadata_version:0,message:messages, + }]}); + if (endpoint === "/agent/run") { + const request=route.request().postDataJSON(); + runs.push({request,core:fixture.draft.agent_core,cm:fixture.draft.enable_context_manager}); + const answer=runs.length===1?"CONTINUED_ALPHA":"CONTINUED_BACK_ALPHA"; + messages.push({role:"user",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:request.query}); + messages.push({role:"assistant",message_id:messages.length+1,message_index:messages.length+1,status:"completed", + message:[{type:"final_answer",content:answer,unit_index:0}]}); + const events=[{type:"agent_new_run",content:request.query},{type:"final_answer",content:answer},{type:"agent_finish",content:""}]; + return route.fulfill({status:200,contentType:"text/event-stream",headers:{conversation_id:"101"}, + body:events.map(value=>`data: ${JSON.stringify(value)}\n\n`).join("")}); + } + return route.fallback(); + }); + const openOld = async () => { + await page.goto(`${baseUrl}/zh/newchat?thread_id=101`); + await expect(page.getByText("OLD_CONVERSATION_ALPHA",{exact:false}).first()).toBeVisible(); + const memoryDialog=page.getByRole("dialog",{name:"尚未配置向量模型"}); + if (await memoryDialog.isVisible()) await memoryDialog.getByRole("button",{name:"关闭记忆"}).click(); + }; + const configure = async (core: string, cm: boolean) => { + await page.goto(`${baseUrl}/zh/agents/42`); + await expect(page.getByText(label("agent.core.label"),{exact:true})).toBeAttached(); + if (!(await page.getByText(label("agent.core.label"),{exact:true}).isVisible())) { + await page.getByText(label("agent.highFidelity.advancedTitle"),{exact:true}).click(); + } + await page.locator(".ant-select").filter({has:page.locator("#agent-core")}).click(); + await page.locator(".ant-select-dropdown:visible .ant-select-item-option-content").filter({hasText:new RegExp(`^${core}$`)}).click(); + const toggle=page.getByRole("switch",{name:label("agent.core.contextManager")}); + await expect(toggle).toBeEnabled(); + await toggle.click(); + await expect.poll(()=>fixture.draft.enable_context_manager).toBe(cm); + await page.reload(); + await expect(page.getByText(label("agent.core.label"),{exact:true})).toBeAttached(); + if (!(await page.getByText(label("agent.core.label"),{exact:true}).isVisible())) { + await page.getByText(label("agent.highFidelity.advancedTitle"),{exact:true}).click(); + } + await expect(page.getByRole("switch",{name:label("agent.core.contextManager")})).toHaveAttribute("aria-checked",String(cm)); + }; + await contract.precondition(contract.expectedIds("preconditions")[0],async()=>{ + expect((await page.request.get(baseUrl)).ok()).toBe(true); + }); + const steps=contract.expectedIds("steps"); + await contract.step(steps[0],async()=>{ + await openOld(); + expect(runs).toHaveLength(0); + await configure("Jiuwen",true); + await openOld(); + expect(runs).toHaveLength(0); + }); + await contract.step(steps[1],async()=>{ + await page.getByRole("textbox").fill("RECALL_CURRENT_ONLY"); + await page.getByRole("button",{name:"发送",exact:true}).click(); + await expect(page.getByText("CONTINUED_ALPHA",{exact:false}).first()).toBeVisible(); + expect(runs).toHaveLength(1); + expect(runs[0].core).toBe("jiuwen"); + expect(runs[0].cm).toBe(true); + expect(runs[0].request.conversation_id).toBe(101); + expect(runs[0].request.agent_core).toBeUndefined(); + await configure("smolagents",false); + await openOld(); + await expect(page.getByText("CONTINUED_ALPHA",{exact:false}).first()).toBeVisible(); + expect(runs).toHaveLength(1); + await page.getByRole("textbox").fill("RECALL_BACK_ONLY"); + await page.getByRole("button",{name:"发送",exact:true}).click(); + await expect(page.getByText("CONTINUED_BACK_ALPHA",{exact:false}).first()).toBeVisible(); + expect(runs).toHaveLength(2); + expect(runs[1].core).toBe("smolagents"); + expect(runs[1].cm).toBe(false); + expect(runs[1].request.conversation_id).toBe(101); + }); + const assertions=contract.expectedIds("assertions"); + await contract.assertion(assertions[0],async()=>{ + expect(messages[0].message).toBe("Analyze the original file"); + expect(JSON.stringify(messages[1])).toContain("old-call"); + expect(fixture.updates.some(value=>value.agent_core==="jiuwen")).toBe(true); + }); + await contract.assertion(assertions[1],async()=>{ + expect(runs.map(value=>value.request.query)).toEqual(["RECALL_CURRENT_ONLY","RECALL_BACK_ONLY"]); + expect(fixture.unexpectedWrites).toEqual([]); + }); +}); diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D5-001/case.yaml b/test-e2e/cases/JIUWEN-CONTEXT-D5-001/case.yaml new file mode 100644 index 0000000000..8d7b3a7066 --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D5-001/case.yaml @@ -0,0 +1,79 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-CONTEXT-D5-001 + feature_id: JIUWEN-CONTEXT + requirement_ids: + - JIUWEN-M4-20261016 + business_rule_ids: + - JIUWEN-CONTEXT-RULE-003 + title: Persisted Core change and saved CM D5 + type: RELIABILITY + priority: P0 + status: active + automation: automated + objective: Protect accepted checkpoints and recover from invalid or cancelled Jiuwen + summaries after a configured Core change. + preconditions: + - Controlled model HTTP endpoint and installed Jiuwen/smolagents; isolated PostgreSQL + for runtime database proof. + test_data: + profile: mock + steps: + - order: 1 + action: Persist completed smolagents history and a valid checkpoint, change effective + configuration to Jiuwen with CM enabled, and inject invalid summary output or + a blocked summary read. + expected: Neither fault overwrites or appends an invalid checkpoint; historical + messages and tool effects remain unchanged. Cancelled reads close, stop within + ten seconds and send no main follow-up model request. + - order: 2 + action: Restore the controlled model endpoint, reopen the same conversation and + follow up; also query with a different owner, tenant or invalid current-message + boundary. + expected: An authorized new run completes from preserved history; old checkpoint + content remains unchanged and unauthorized history queries return no data. Managed + workers drain without STUCK tasks. + expected_results: + - Neither fault overwrites or appends an invalid checkpoint; historical messages + and tool effects remain unchanged. Cancelled reads close, stop within ten seconds + and send no main follow-up model request. + - An authorized new run completes from preserved history; old checkpoint content + remains unchanged and unauthorized history queries return no data. Managed workers + drain without STUCK tasks. + forbidden_side_effects: + - No historical tool replay, second authoritative memory, checkpoint corruption, + cross-owner history or duplicate current input. + acceptance: + - id: AC-01 + description: Configuration-side Core changes preserve old conversations and saved + CM semantics. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 240 + risk_type: RELIABILITY + risk: Summary failure, cancellation or unavailable history boundary corrupts facts + or replays tools. + condition: + - Summary response is invalid or blocked; history owner or current-message boundary + is invalid. + metrics: + - Terminal outcome, subsequent calls, active futures/workers and elapsed close time. + thresholds: + - No invalid checkpoint write; zero historical effects; cancelled workers drain + within ten seconds. + recovery_expectations: + - Fresh authorized follow-up reads the previous valid checkpoint and succeeds. diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D5-001/execution.yaml b/test-e2e/cases/JIUWEN-CONTEXT-D5-001/execution.yaml new file mode 100644 index 0000000000..2aa02f081e --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D5-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-CONTEXT-D5-001 +implementations: +- framework: pytest + file: test_context.py + selector: test_context_failure_preserves_history +timeout_seconds: 240 +acceptance_bindings: + AC-01: + - test_context_failure_preserves_history +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-CONTEXT-D5-001/test_context.py b/test-e2e/cases/JIUWEN-CONTEXT-D5-001/test_context.py new file mode 100644 index 0000000000..f8b0913580 --- /dev/null +++ b/test-e2e/cases/JIUWEN-CONTEXT-D5-001/test_context.py @@ -0,0 +1,35 @@ +import asyncio +import sys +from pathlib import Path +import pytest +ROOT=Path(__file__).resolve().parents[3] +sys.path[:0]=[str(ROOT/'sdk'),str(ROOT/'backend'),str(ROOT/'test-e2e/infra/automation')] +from jiuwen_context_runtime import context_database,run_context_scenario +from jiuwen_bridge_runtime import stop_infrastructure + +@pytest.fixture(scope="module",autouse=True) +def cleanup(): + yield + stop_infrastructure() + +pytestmark=[pytest.mark.case_id("JIUWEN-CONTEXT-D5-001"),pytest.mark.stage("D5")] + +@pytest.mark.parametrize("protocol",["native","text"]) +@pytest.mark.parametrize("fault",["invalid_summary","cancel_summary"]) +def test_context_failure_preserves_history(monkeypatch,protocol,fault): + with context_database(monkeypatch) as engine: + result=asyncio.run(run_context_scenario(monkeypatch,engine,"smolagents",protocol,True,fault)) + assert result["effects"]==["once"] + assert len(result["checkpoints_before_recovery"]) == 1 + assert result["checkpoints"][0] == result["checkpoints_before_recovery"][0] + assert "OLD_FACT_ALPHA" in result["checkpoints"][0] + assert result["summaries"] + if fault=="cancel_summary": + assert result["infos"][1].attempt_outcome=="stopped" + assert result["phase"]["closed"] + assert result["phase"]["cancel_elapsed"] < 10 + assert not [body for phase,body in result["calls"] if phase=="followup"] + else: + assert result["infos"][1].attempt_outcome=="completed" + assert result["infos"][-1].attempt_outcome=="completed" + assert len([body for phase,body in result["calls"] if phase=="reload"])==1 diff --git a/test-e2e/cases/JIUWEN-IMAGE-D3-001/case.yaml b/test-e2e/cases/JIUWEN-IMAGE-D3-001/case.yaml new file mode 100644 index 0000000000..b7b40dc36b --- /dev/null +++ b/test-e2e/cases/JIUWEN-IMAGE-D3-001/case.yaml @@ -0,0 +1,68 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-IMAGE-D3-001 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-M3-20261015 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-001 + title: Uploaded image through deployed Jiuwen file assistant and follow-up + type: AGENT-IT + priority: P1 + status: active + automation: automated + objective: Prove actual image decoding through an authorized saved assistant, configured + vision model and persisted conversation. + preconditions: + - Authorized deployed Jiuwen file assistant with analyze_image; existing authorized + image upload and configured real vision model; machine-local test IDs. + test_data: + profile: real_smoke + steps: + - order: 1 + action: Create a temporary conversation and analyze the authorized existing image + using the saved Jiuwen assistant. + expected: Actual analyze_image result is nonempty, its output is not an error, + exactly one completed root terminal and a substantive final answer are saved. + - order: 2 + action: Ask a follow-up about the previously analyzed image with no new attachment + and empty client history. + expected: Answer uses previous image facts, no image tool replay; original messages + unchanged, DB indices monotonic; reload and cleanup succeed. + expected_results: + - Real image analysis completes with a successful tool observation, substantive + visual content and persisted completed result. + - Follow-up uses DB history without replay; original messages are unchanged and + temporary conversation is cleaned. + forbidden_side_effects: + - No modification to the saved assistant or original conversation, no arbitrary + model fallback, no HTTP-200-only pass, no credentials or signed URLs in public + test output. + acceptance: + - id: AC-01 + description: Real image execution and safe persisted follow-up. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: real_smoke + required_services: + - model-provider + timeout_seconds: 600 + integration_boundary: + - Authenticated deployed runtime HTTP and DB, Jiuwen ModelService, real image understanding + tool/model. + observations: + - Tool results, root terminal, visual answer, DB snapshots and subsequent no-tool + response. + cleanup: + - Delete only the run-owned temporary conversation; retain sanitized evidence outside + Git. diff --git a/test-e2e/cases/JIUWEN-IMAGE-D3-001/deployed_image.py b/test-e2e/cases/JIUWEN-IMAGE-D3-001/deployed_image.py new file mode 100644 index 0000000000..296b086e20 --- /dev/null +++ b/test-e2e/cases/JIUWEN-IMAGE-D3-001/deployed_image.py @@ -0,0 +1,111 @@ +"""Qualify an authorized image through the deployed assistant without altering its configuration.""" + +import json +import sys +import time + +import httpx +from database.client import get_db_session +from database.db_models import AgentInfo, ConversationMessage, ConversationRecord +from utils.auth_utils import generate_session_jwt + + +def main(): + agent_id, source_id = int(sys.argv[1]), int(sys.argv[2]) + user, expected = sys.argv[3:5] + with get_db_session() as session: + source = session.query(ConversationMessage).filter_by(message_id=source_id, message_role="user").one() + owner = session.query(ConversationRecord).filter_by(conversation_id=source.conversation_id).one() + assert owner.created_by == user, "Source image ownership mismatch" + files = json.loads(source.minio_files) if isinstance(source.minio_files, str) else source.minio_files + assert files and any(f.get("type", "").startswith("image/") for f in files), "Image fixture required" + assert session.query(AgentInfo).filter_by(agent_id=agent_id, agent_core="jiuwen", delete_flag="N").count() + headers = {"Authorization": "Bearer " + generate_session_jwt(user, expires_in=1800)} + conversation = None + with httpx.Client(base_url="http://127.0.0.1:5014", headers=headers, trust_env=False, timeout=240) as client: + try: + response = client.put("/conversation/create", json={"title": "Jiuwen image regression verification"}) + assert response.status_code == 200, "Conversation creation failed" + conversation = response.json()["data"]["conversation_id"] + saved = None + counts = [] + for index, query in enumerate( + [ + "分析一下上传图片的内容,请调用图片分析工具,根据实际结果回答。", + "根据上轮已经完成的图片分析,图中衣服的主要颜色是什么?只用已有结果,不要重新调用任何工具。", + ] + ): + response = client.post( + "/agent/run", + json={ + "agent_id": agent_id, + "conversation_id": conversation, + "query": query, + "history": [], + "minio_files": files if not index else [], + }, + ) + assert response.status_code == 200, "Runtime HTTP failed" + events = [ + json.loads(line[5:].strip()) for line in response.text.splitlines() if line.startswith("data:") + ] + terminal = [e for e in events if e.get("type") == "run_terminal" and not e.get("invocation_id")] + assert len(terminal) == 1 and terminal[0]["status"] == "completed", "Root execution failed" + calls = [e for e in events if e.get("type") == "tool"] + if not index: + image_calls = [e for e in calls if e.get("tool_name") == "analyze_image"] + assert len(image_calls) == 1, "Image must actually be analyzed once" + results = [ + e + for e in events + if e.get("type") == "tool_result" and e.get("local_call_id") == image_calls[0]["local_call_id"] + ] + assert len(results) == 1 and results[0].get("is_error") is False, "Image tool failed" + text = json.dumps(results[0].get("result"), ensure_ascii=False) + assert len(text) > 40 and expected in text, "Visual result lacks expected fixture fact" + else: + assert not calls, "Follow-up replayed a tool" + final = terminal[0].get("final_content") or "" + assert len(final) > 3 and expected in final, "Answer lacks the known image fact" + for _ in range(40): + with get_db_session() as session: + messages = ( + session.query(ConversationMessage) + .filter_by(conversation_id=conversation) + .order_by(ConversationMessage.message_index, ConversationMessage.message_id) + .all() + ) + snapshot = [ + (m.message_id, m.message_index, m.message_role, m.status, m.message_content) + for m in messages + ] + if len(snapshot) == (index + 1) * 2 and snapshot[-1][3] == "completed": + break + time.sleep(0.1) + assert len(snapshot) == (index + 1) * 2 and snapshot[-1][3] == "completed" + assert [m[1] for m in snapshot] == list(range(len(snapshot))), "Message order regressed" + assert expected in snapshot[-1][4] + if saved: + assert snapshot[: len(saved)] == saved, "Earlier messages changed" + saved = snapshot + counts.append(len(calls)) + loaded = client.get(f"/conversation/{conversation}") + assert loaded.status_code == 200 and expected in loaded.text, "Reload lost image facts" + finally: + if conversation: + assert client.delete(f"/conversation/{conversation}").status_code == 200, "Cleanup failed" + print( + "IMAGE_RESULT=" + + json.dumps( + {"completed": True, "tool_counts": counts, "messages": 4, "followup_no_replay": True, "cleanup": True} + ), + flush=True, + ) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print("IMAGE_FAILURE=" + type(error).__name__, flush=True) + raise SystemExit(1) diff --git a/test-e2e/cases/JIUWEN-IMAGE-D3-001/execution.yaml b/test-e2e/cases/JIUWEN-IMAGE-D3-001/execution.yaml new file mode 100644 index 0000000000..0d241a4f42 --- /dev/null +++ b/test-e2e/cases/JIUWEN-IMAGE-D3-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-IMAGE-D3-001 +implementations: +- framework: pytest + file: test_image.py + selector: test_deployed_image_and_followup + profiles: [real_smoke] +timeout_seconds: 600 +acceptance_bindings: + AC-01: [test_deployed_image_and_followup] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-IMAGE-D3-001/test_image.py b/test-e2e/cases/JIUWEN-IMAGE-D3-001/test_image.py new file mode 100644 index 0000000000..8840c714f1 --- /dev/null +++ b/test-e2e/cases/JIUWEN-IMAGE-D3-001/test_image.py @@ -0,0 +1,70 @@ +"""Real image model and authenticated deployment qualification, with local fixture IDs.""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.case_id("JIUWEN-IMAGE-D3-001"), pytest.mark.stage("D3")] + + +def test_deployed_image_and_followup(): + here = Path(__file__).resolve().parent + container = os.environ.get("NEXENT_REAL_SMOKE_CONTAINER", "nexent-runtime") + values = [ + os.environ.get(key) + for key in ( + "NEXENT_IMAGE_AGENT_ID", + "NEXENT_IMAGE_SOURCE_MESSAGE_ID", + "NEXENT_IMAGE_USER_ID", + "NEXENT_IMAGE_EXPECTED_FACT", + ) + ] + assert all(values), "Authorized image fixture IDs and expected visual fact are required" + target = "/tmp/jiuwen_image_case.py" + subprocess.run( + ["docker", "cp", str(here / "deployed_image.py"), container + ":" + target], check=True, capture_output=True + ) + try: + result = subprocess.run( + [ + "docker", + "exec", + "-e", + "PYTHONPATH=/opt/backend", + "-w", + "/opt/backend", + container, + "/opt/backend/.venv/bin/python", + target, + *values, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=550, + ) + safe = [line for line in result.stdout.splitlines() if line.startswith(("IMAGE_RESULT=", "IMAGE_FAILURE="))] + code = result.returncode + del result + print("\n".join(safe)) + assert code == 0, "Image runtime verification failed; only sanitized outcomes are shown" + data = json.loads(next(line.split("=", 1)[1] for line in safe if line.startswith("IMAGE_RESULT="))) + assert data["completed"] and data["followup_no_replay"] and data["cleanup"] + assert data["tool_counts"] == [1, 0] and data["messages"] == 4 + finally: + subprocess.run( + [ + "docker", + "exec", + container, + "/opt/backend/.venv/bin/python", + "-c", + "from pathlib import Path; Path('/tmp/jiuwen_image_case.py').unlink(missing_ok=True)", + ], + check=True, + capture_output=True, + ) diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml new file mode 100644 index 0000000000..c63ab6d783 --- /dev/null +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml @@ -0,0 +1,70 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-MODEL-BRIDGE-D3-001 + feature_id: JIUWEN-MODEL-BRIDGE + requirement_ids: + - JIUWEN-MODEL-BRIDGE-20261015 + business_rule_ids: + - JIUWEN-MODEL-BRIDGE-RULE-001 + - JIUWEN-MODEL-BRIDGE-RULE-002 + title: Jiuwen canonical model service and dual-protocol runtime + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Exercise actual runtime and transport boundaries beyond isolated mapping/parser + UT. + preconditions: + - Installed compatible Jiuwen dependency and shared ModelService; controlled loopback + HTTP/SSE provider and fixture tools. + test_data: + profile: mock + steps: + - order: 1 + action: Run the actual Jiuwen adapter with a trusted snapshot and the selected + protocol. + expected: The actual factory/adapter/Jiuwen loop uses canonical ModelService and + real local HTTP/SSE; native and text/unknown capability paths return one final + result and one tool effect. + - order: 2 + action: Run the actual Jiuwen adapter with a trusted snapshot and the selected + protocol. + expected: Text requests contain no native tools; actual tool observations reach + the next request; native provider IDs are preserved. + expected_results: + - The actual factory/adapter/Jiuwen loop uses canonical ModelService and real local + HTTP/SSE; native and text/unknown capability paths return one final result and + one tool effect. + - Text requests contain no native tools; actual tool observations reach the next + request; native provider IDs are preserved. + forbidden_side_effects: + - No native Jiuwen provider HTTP or CodeAgent loop; no unauthorized executable bindings, + credentials or duplicate effects. + acceptance: + - id: AC-01 + description: Prove runtime boundary and forbidden effects. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + integration_boundary: + - Nexent factory/worker to Jiuwen to ModelService to real HTTP/SSE to controlled + execution. + observations: + - Provider request capture, execution counter, native events and terminal outcome. + cleanup: + - Release native session/tools and managed threads; close loopback listener and + HTTP response. diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/execution.yaml b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/execution.yaml new file mode 100644 index 0000000000..42346182dc --- /dev/null +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: JIUWEN-MODEL-BRIDGE-D3-001 +implementations: +- framework: pytest + file: test_runtime.py + selector: test_factory_service_protocol_loop + profiles: + - mock +acceptance_bindings: + AC-01: + - test_factory_service_protocol_loop +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py new file mode 100644 index 0000000000..57b56c05b2 --- /dev/null +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py @@ -0,0 +1,43 @@ +"""Canonical ModelService through the real Nexent Jiuwen worker.""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT / "sdk"), str(ROOT / "backend"), str(ROOT / "test-e2e/infra/automation")] + +from jiuwen_bridge_runtime import run_worker, stop_infrastructure + +pytestmark = [pytest.mark.case_id("JIUWEN-MODEL-BRIDGE-D3-001"), pytest.mark.stage("D3")] + + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + stop_infrastructure() + + +@pytest.mark.parametrize("protocol", ["native", "text", "unknown"]) +def test_factory_service_protocol_loop(monkeypatch, protocol): + info, state, output = asyncio.run(run_worker(monkeypatch, protocol)) + assert info.attempt_outcome == "completed", output[-1:] + assert state["tool_calls"] == [(2, 3)] + assert len(state["requests"]) == 2 + assert "fixture-only" not in json.dumps(state["requests"]) + assert state["first_fragment_no_tool"] is True + assert all(bool(body.get("tools")) == (protocol == "native") for body in state["requests"]) + assert sum(event.get("type") == "final_answer" for event in output) == 1 + assert any("SPIKE_FINAL_RESULT_5" in str(event) for event in output) + observations = json.dumps(state["requests"][1]["messages"]) + assert "Tool execution error" not in observations + if protocol == "native": + assert [tool["function"]["name"] for tool in state["requests"][0]["tools"]] == ["add"] + assert any(item.get("tool_call_id") == "provider-call-1" and item["content"] == "5" + for item in state["requests"][1]["messages"]) + else: + assert "historical_tool_result" in observations + assert any('"logs": "5' in str(item) for item in state["requests"][1]["messages"]) diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/case.yaml b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/case.yaml new file mode 100644 index 0000000000..96808f5ce9 --- /dev/null +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/case.yaml @@ -0,0 +1,79 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-MODEL-BRIDGE-D5-001 + feature_id: JIUWEN-MODEL-BRIDGE + requirement_ids: + - JIUWEN-MODEL-BRIDGE-20261015 + - JIUWEN-M6-20261016 + business_rule_ids: + - JIUWEN-MODEL-BRIDGE-RULE-003 + - JIUWEN-MODEL-BRIDGE-RULE-004 + title: Jiuwen model bridge failure and cancellation boundaries + type: RELIABILITY + priority: P0 + status: active + automation: automated + objective: Exercise actual runtime and transport boundaries beyond isolated mapping/parser + UT. + preconditions: + - Installed compatible Jiuwen dependency and shared ModelService; controlled loopback + HTTP/SSE provider and fixture tools. + test_data: + profile: mock + steps: + - order: 1 + action: Inject the listed failure or cancellation while consuming the actual runtime + stream. + expected: Persistently invalid JSON fails after at most one explicit argument + correction with zero tool effects; incomplete/ambiguous blocks, unknown finish + and interrupted streams fail without logical retry. + - order: 2 + action: Inject the listed failure or cancellation while consuming the actual runtime + stream. + expected: Visible text survives interruption; cancellation closes the response; + pre-visible transient recovery is bounded by ModelService and does not replay + tools. Identical repeated usage is accepted once; conflicting usage is rejected + by the decoder UT. + expected_results: + - Persistently invalid JSON fails after at most one explicit argument correction + with zero tool effects; incomplete/ambiguous blocks, unknown finish and interrupted + streams fail without logical retry. + - Visible text survives interruption; cancellation closes the response; pre-visible + transient recovery is bounded by ModelService and does not replay tools. Identical + repeated usage is accepted once; conflicting usage is rejected by the decoder + UT. + forbidden_side_effects: + - No incomplete tool execution, hidden stream replay, fallback core/model/protocol, + leaked response or continued model retry after cancellation. + acceptance: + - id: AC-01 + description: Prove runtime boundary and forbidden effects. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + risk_type: RELIABILITY + risk: Incomplete responses or duplicate recovery can execute stale or unauthorized + effects. + condition: + - Inject malformed/truncated streams, service errors and cancellation. + metrics: + - Tool effects, physical HTTP attempts, preserved partial content and response closure. + thresholds: + - Zero effects for invalid/incomplete calls; no retries after visible failure or + cancellation; at most three physical attempts. + recovery_expectations: + - Subsequent isolated run works and resources are closed. diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/execution.yaml b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/execution.yaml new file mode 100644 index 0000000000..a50822cbeb --- /dev/null +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/execution.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' +case_id: JIUWEN-MODEL-BRIDGE-D5-001 +implementations: +- framework: pytest + file: test_reliability.py + selector: test_bridge_ + profiles: + - mock +acceptance_bindings: + AC-01: + - test_bridge_incomplete_response_zero_effects + - test_bridge_transient_retry_uses_service_only + - test_bridge_cancellation_closes_visible_stream + - test_bridge_exhaustion_does_not_multiply_attempts + - test_bridge_identical_usage_is_not_retried +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/test_reliability.py b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/test_reliability.py new file mode 100644 index 0000000000..040e302073 --- /dev/null +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D5-001/test_reliability.py @@ -0,0 +1,69 @@ +"""Incomplete responses must not cause execution or hidden stream replay.""" + +import asyncio +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT / "sdk"), str(ROOT / "backend"), str(ROOT / "test-e2e/infra/automation")] + +from jiuwen_bridge_runtime import run_worker, stop_infrastructure + +pytestmark = [pytest.mark.case_id("JIUWEN-MODEL-BRIDGE-D5-001"), pytest.mark.stage("D5")] + + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + stop_infrastructure() + + +@pytest.mark.parametrize("protocol,scenario", [ + ("native", "bad_args"), ("native", "unknown_finish"), ("native", "auth"), + ("native", "broken_stream"), ("text", "truncated"), ("text", "broken_stream"), + ("text", "ambiguous"), +]) +def test_bridge_incomplete_response_zero_effects(monkeypatch, protocol, scenario): + info, state, output = asyncio.run(run_worker(monkeypatch, protocol, scenario)) + assert info.attempt_outcome == "failed", output[-1:] + assert state["tool_calls"] == [] + assert len(state["requests"]) == (2 if scenario == "bad_args" else 1) + if protocol == "text": + assert any("" in str(event) for event in output) + + +@pytest.mark.parametrize("scenario", ["transient", "transient_after_tool"]) +def test_bridge_transient_retry_uses_service_only(monkeypatch, scenario): + info, state, output = asyncio.run(run_worker(monkeypatch, "native", scenario)) + assert info.attempt_outcome == "completed", output[-1:] + assert len(state["requests"]) == 3 + assert state["tool_calls"] == [(2, 3)] + assert sum(event.get("type") == "step_count" for event in output) == 2 + + +def test_bridge_cancellation_closes_visible_stream(monkeypatch): + info, state, output = asyncio.run(run_worker(monkeypatch, "native", "cancel")) + assert info.attempt_outcome == "stopped", output[-1:] + assert state["tool_calls"] == [] + assert len(state["requests"]) == 1 + assert state.get("cancel_response_closed") is True + assert any("PARTIAL" in str(event) for event in output) + + +def test_bridge_exhaustion_does_not_multiply_attempts(monkeypatch): + info, state, output = asyncio.run(run_worker(monkeypatch, "native", "unavailable")) + assert info.attempt_outcome == "failed" + assert state["tool_calls"] == [] + assert len(state["requests"]) == 3 + assert sum(event.get("type") == "step_count" for event in output) == 1 + + + +def test_bridge_identical_usage_is_not_retried(monkeypatch): + info, state, output = asyncio.run(run_worker(monkeypatch, "native", "duplicate_usage")) + assert info.attempt_outcome == "completed" + assert len(state["requests"]) == 2 and state["tool_calls"] == [(2, 3)] + steps = [event for event in output if event["type"] == "step_observation"] + assert len(steps) == 2 and all(event["usage"]["total_tokens"] == 5 for event in steps) diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml new file mode 100644 index 0000000000..39cb6ff465 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml @@ -0,0 +1,74 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-OUTPUT-D3-001 + feature_id: JIUWEN-OUTPUT + requirement_ids: + - JIUWEN-M5-20261016 + business_rule_ids: + - JIUWEN-OUTPUT-RULE-001 + - JIUWEN-OUTPUT-RULE-002 + - JIUWEN-OUTPUT-RULE-003 + - JIUWEN-OUTPUT-RULE-005 + title: Jiuwen output contract and display D3 + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Preserve ordered runtime facts and explicit terminal semantics through + streaming, persistence and display. + preconditions: + - Installed Jiuwen and existing local controlled model/resource services; isolated + PostgreSQL for persistence; frontend fixture server for D4. + test_data: + profile: mock + steps: + - order: 1 + action: Run real Jiuwen through controlled HTTP native/text model endpoints and + authorized tool/MCP/Skill or child/A2A integrations. + expected: Observations retain ordered content/reasoning blocks, exact tool results, + full provider IDs, unique local IDs and invocation grouping; usage absent is + unknown. + - order: 2 + action: Pass output through the backend stream persistence path and reload the + saved conversation. + expected: Exactly one explicit RunResult determines status and final content; + JSON envelopes and original results survive storage, with final blocks unambiguously + linked. + expected_results: + - Observations retain ordered content/reasoning blocks, exact tool results, full + provider IDs, unique local IDs and invocation grouping; usage absent is unknown. + Public step capacity and processing mode agree with the actual Nexent ContextManager + configuration and survive persistence. + - Exactly one explicit RunResult determines status and final content; JSON envelopes + and original results survive storage, with final blocks unambiguously linked. + forbidden_side_effects: + - No inferred success from EOF or text; no duplicate final, lost partial, cross-invocation + tool pairing, invented reasoning or native HTTP bypass. + acceptance: + - id: AC-01 + description: Public output and terminal/display contract preserves execution facts. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 240 + integration_boundary: + - Real Jiuwen/ModelService/resource protocols to backend stream and PostgreSQL persistence. + observations: + - Public events, result status, tool and invocation identities, saved envelopes + and cleanup. + cleanup: + - Release native session/tools and managed threads; close loopback listener and + HTTP response. diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/execution.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/execution.yaml new file mode 100644 index 0000000000..4f9b9998b4 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-OUTPUT-D3-001 +implementations: +- framework: pytest + file: test_output.py + selector: test_runtime_output_roundtrip +timeout_seconds: 300 +acceptance_bindings: + AC-01: + - test_runtime_output_roundtrip +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py new file mode 100644 index 0000000000..bafa5dcb84 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py @@ -0,0 +1,70 @@ +import asyncio +import json +import sys +from pathlib import Path +import pytest +ROOT=Path(__file__).resolve().parents[3] +sys.path[:0]=[str(ROOT/'sdk'),str(ROOT/'backend'),str(ROOT/'test-e2e/infra/automation')] +from jiuwen_output_runtime import context_database,persist_output +from jiuwen_resource_runtime import resource_provider,run_resources +from jiuwen_collaboration_runtime import run_collaboration +from jiuwen_bridge_runtime import run_worker,stop_infrastructure + +@pytest.fixture(scope="module",autouse=True) +def cleanup(): + yield + stop_infrastructure() + + +pytestmark=[pytest.mark.case_id("JIUWEN-OUTPUT-D3-001"),pytest.mark.stage("D3")] + +@pytest.mark.parametrize("protocol",["native","text"]) +@pytest.mark.parametrize("resource",["chain","jiuwen","smolagents","a2a"]) +def test_runtime_output_roundtrip(monkeypatch,tmp_path,protocol,resource): + with context_database(monkeypatch): + if resource=="chain": + with resource_provider(tmp_path) as url: + info,state,output,calls,effects,_=asyncio.run(run_resources(monkeypatch,tmp_path,url,protocol)) + assert calls==[(2,3)] and state["mcp_calls"]==1 and effects=="x" + assert state["saw_skill"] + else: + info,state,output,_=asyncio.run(run_collaboration(monkeypatch,"jiuwen",resource,protocol,"JSONRPC")) + assert state["auth_ok"] and all(state["auth_ok"]) + assert info.attempt_outcome=="completed",output[-2:] + root=[e for e in output if e.get("runtime_mode")=="native_agent" and not e.get("invocation_id")] + assert sum(e["type"]=="run_terminal" for e in root)==1 + assert not any(e["type"]=="model_output_thinking" for e in root) + assert any(e["type"]=="step_observation" for e in root) + from nexent.core.agents.context import ContextManagerConfig + from nexent.core.agents.context.policy import resolve_policy + cfg = info.agent_config.context_manager_config or ContextManagerConfig() + expected_capacity = {"context_window_tokens": cfg.context_window_tokens or None, + "token_threshold": cfg.token_threshold or None, + "effective_input_limit_tokens": cfg.effective_input_limit_tokens or None, + "context_processing_mode": resolve_policy(cfg.policy_layers).processing_mode.value} + assert all(e["context_capacity"] == expected_capacity for e in root if e["type"] == "step_observation") + assert [e["sequence"] for e in root]==sorted(set(e["sequence"] for e in root)) + tools=[e for e in output if e.get("runtime_mode")=="native_agent" and e["type"]=="tool"] + results=[e for e in output if e.get("runtime_mode")=="native_agent" and e["type"]=="tool_result"] + assert tools and results + for call in tools: + result=next(r for r in results if r["local_call_id"]==call["local_call_id"]) + assert result.get("invocation_id")==call.get("invocation_id") + assert result["provider_call_id"]==call["provider_call_id"] + assert result["result"] + if protocol=="native":assert any(e["provider_call_id"] for e in tools) + assistant,saved,wire=asyncio.run(persist_output(monkeypatch,info,output)) + live_capacity = {(e["turn_id"], e["sequence"]): e["context_capacity"] + for e in root if e["type"] == "step_observation"} + saved_capacity = {(e["turn_id"], e["sequence"]): e["context_capacity"] + for e in saved if e["type"] == "step_observation" and not e.get("invocation_id")} + assert saved_capacity == live_capacity + assert assistant["status"]=="completed" + assert assistant["message_content"]==info.run_result.final_content + assert sum(e["type"]=="run_terminal" and not e.get("invocation_id") for e in saved)==1 + assert {e["local_call_id"] for e in results}=={e["local_call_id"] for e in saved if e["type"]=="tool_result"} + for call in results: + assert next(e for e in saved if e["type"]=="tool_result" and e["local_call_id"]==call["local_call_id"])["result"]==call["result"] + terminal=next(e for e in saved if e["type"]=="run_terminal" and not e.get("invocation_id")) + assert terminal["final_block_ids"] + assert "fixture-only" not in json.dumps(saved) diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D4-001/case.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D4-001/case.yaml new file mode 100644 index 0000000000..86706f3300 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D4-001/case.yaml @@ -0,0 +1,72 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: JIUWEN-OUTPUT-D4-001 + feature_id: JIUWEN-OUTPUT + requirement_ids: + - JIUWEN-M5-20261016 + - JIUWEN-UNIFIED-OUTPUT-20261016 + business_rule_ids: + - JIUWEN-OUTPUT-RULE-001 + - JIUWEN-OUTPUT-RULE-002 + - JIUWEN-OUTPUT-RULE-003 + - JIUWEN-OUTPUT-RULE-004 + title: Jiuwen output contract and display D4 + type: E2E + priority: P1 + status: active + automation: automated + objective: Preserve ordered runtime facts and explicit terminal semantics through + streaming, persistence and display. + preconditions: + - Installed Jiuwen and existing local controlled model/resource services; isolated + PostgreSQL for persistence; frontend fixture server for D4. + test_data: + profile: mock + locale: zh + viewport: + width: 1920 + height: 1080 + steps: + - order: 1 + action: Open an old chat, send a Jiuwen request containing intermediate text, + reasoning, business tools, MCP, Skills, child and A2A groups. + expected: Execution process shows same-level ordered content and tool cards, child/A2A + results and one final answer outside the process. All Core output uses the existing + reasoning, tool, final and child components; no runtime-output renderer. + - order: 2 + action: Reload the chat, then load failed/stopped partial and legacy Code messages. + expected: Saved and live projections agree; partial/error remains visible with + failed/stopped status, unknown usage is not zero and legacy messages still render. + expected_results: &id001 + - Execution process shows same-level ordered content and tool cards, child/A2A results + and one final answer outside the process. All Core output uses the existing reasoning, + tool, final and child components; no runtime-output renderer. + - Saved and live projections agree; partial/error remains visible with failed/stopped + status, unknown usage is not zero and legacy messages still render. + forbidden_side_effects: + - No inferred success from EOF or text; no duplicate final, lost partial, cross-invocation + tool pairing, invented reasoning or native HTTP bypass. + - No Core-specific renderer or duplicate frontend parsing pipeline; no native output + card selected from agent_core. + actor: Authorized Agent development user + journey: + - Open an old chat, send a Jiuwen request containing intermediate text, reasoning, + business tools, MCP, Skills, child and A2A groups. + - Reload the chat, then load failed/stopped partial and legacy Code messages. + final_business_result: *id001 + failure_evidence: + - Fixed Playwright journey assertions, sanitized request payloads and terminal audit. + acceptance: + - id: AC-01 + description: Public output and terminal/display contract preserves execution facts. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + - 2 diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D4-001/execution.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D4-001/execution.yaml new file mode 100644 index 0000000000..44a1f922f6 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D4-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-OUTPUT-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: JIUWEN-OUTPUT-D4-001 +timeout_seconds: 300 +acceptance_bindings: + AC-01: + - JIUWEN-OUTPUT-D4-001 +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D4-001/test.spec.ts b/test-e2e/cases/JIUWEN-OUTPUT-D4-001/test.spec.ts new file mode 100644 index 0000000000..5192c8a6c6 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D4-001/test.spec.ts @@ -0,0 +1,169 @@ +import { journey } from "../../infra/automation/d4/runner/journey"; +import { baseUrl, installEditor } from "../../infra/automation/d4/fixtures/agent-editor-workflows"; +import { join } from "node:path"; + +journey("JIUWEN-OUTPUT-D4-001", async ({page,contract,expect})=>{ + const fixture=await installEditor(page); + Object.assign(fixture.draft,{agent_core:"jiuwen",enable_context_manager:true}); + const messages:any[]=[{role:"user",message_id:1,message_index:1,status:"completed",message:"Old task"}, + {role:"assistant",message_id:2,message_index:2,status:"completed",message:[{type:"model_output_thinking",content:"LEGACY_PROCESS",unit_index:0},{type:"tool",content:"",tool_name:"legacy_tool",tool_arguments:{value:1},tool_call_id:"legacy-call",unit_index:1},{type:"execution_logs",content:"LEGACY_RESULT",tool_call_id:"legacy-call",unit_index:2},{type:"final_answer",content:"LEGACY_ANSWER",unit_index:3}]}]; + const events:any[]=[]; + const seq:Record={}; + const add=(type:string,content:string,extra:Record={})=>{ + const turn=String(extra.turn_id || "root"); + const event={schema_version:1,runtime_mode:"native_agent",agent_core:"jiuwen",type,content,turn_id:turn,sequence:seq[turn]=(seq[turn]||0)+1,...extra}; + events.push(event);return event; + }; + add("model_output","INTERMEDIATE_ONE",{block_id:"one",phase:"partial"}); + add("model_output_deep_thinking","EXPLICIT_REASONING",{block_id:"one",phase:"partial"}); + add("model_output","INTERMEDIATE_TWO",{block_id:"two",phase:"partial"}); + add("step_observation","",{block_id:"one",step_number:1,duration:.2,usage:{origin:"unknown",input_tokens:null,output_tokens:null}}); + for(const [name,result] of [["local_tool","LOCAL_RESULT"],["mcp_tool","MCP_RESULT"],["run_skill_script","SKILL_RESULT"]]){ + add("tool","",{tool_name:name,tool_arguments:{value:1},local_call_id:name,provider_call_id:"provider_"+name}); + add("tool_result",result,{local_call_id:name,provider_call_id:"provider_"+name,result,is_error:false}); + } + add("tool","",{tool_name:"failing_tool",tool_arguments:{},local_call_id:"error-call"}); + add("tool_result","RECOVERABLE_ERROR",{local_call_id:"error-call",result:"RECOVERABLE_ERROR",is_error:true}); + for(const [id,name,result] of [["child","Child agent","CHILD_ANSWER"],["remote","A2A agent","A2A_RESULT"]]){ + events.push({type:"subagent_start",invocation_id:id,content:JSON.stringify({invocation_id:id,agent_name:name,task:"Subtask"})}); + if(id==="child"){ + add("model_output",result,{turn_id:id,invocation_id:id,block_id:id}); + add("run_terminal","",{turn_id:id,invocation_id:id,status:"completed",end_reason:"runner_answer",final_content:result,final_block_ids:[id]}); + }else events.push({type:"final_answer",invocation_id:id,content:result}); + events.push({type:"subagent_end",invocation_id:id,content:JSON.stringify({invocation_id:id})}); + } + add("model_output","FINAL_ONCE",{block_id:"final"}); + events.push({type:"final_answer",content:"FINAL_ONCE"}); + add("run_terminal","",{status:"completed",end_reason:"runner_answer",final_content:"FINAL_ONCE",final_block_ids:["final"]}); + const addFault=(status:string)=>{ + messages.push({role:"user",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:"Fault "+status}); + const turn="fault-"+status; + messages.push({role:"assistant",message_id:messages.length+1,message_index:messages.length+1,status,message:[ + {type:"model_output",content:JSON.stringify({schema_version:1,runtime_mode:"native_agent",agent_core:"jiuwen",turn_id:turn,sequence:1,type:"model_output",block_id:turn,content:"PARTIAL_"+status})}, + {type:"run_terminal",content:JSON.stringify({schema_version:1,runtime_mode:"native_agent",agent_core:"jiuwen",turn_id:turn,sequence:2,type:"run_terminal",status,end_reason:status,content:"",final_content:"",final_block_ids:[]})}, + ]}); + }; + let runs=0; + await page.route("**/api/**",async route=>{ + const path=new URL(route.request().url()).pathname.replace(/^.*\/api/,""); + const respond=(data:unknown)=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(data)}); + if(path==="/memory/config/load")return respond({MEMORY_SWITCH:"N"}); + if(path==="/conversation/list")return respond({code:0,data:{items:[{conversation_id:101,conversation_title:"Output contract",agent_id:42,create_time:Date.now(),update_time:Date.now()}],metadata:{total:1,today:1,last_7_days:0,older:0}}}); + if(path==="/conversation/101")return respond({code:0,data:[{conversation_id:101,conversation_title:"Output contract",agent_id:42,chat_mode:"execution",create_time:Date.now(),runtime_metadata:{},runtime_metadata_version:0,message:messages}]}); + if(path==="/agent/run"){ + runs++; + messages.push({role:"user",message_id:3,message_index:3,status:"completed",message:"Current task"}); + messages.push({role:"assistant",message_id:4,message_index:4,status:"completed",message:events.map((event,index)=>({...event,unit_index:index,content:event.runtime_mode?JSON.stringify(event):event.content}))}); + return route.fulfill({status:200,contentType:"text/event-stream",headers:{conversation_id:"101"},body:events.map(event=>`data: ${JSON.stringify(event)}\n\n`).join("")}); + } + return route.fallback(); + }); + await contract.precondition(contract.expectedIds("preconditions")[0],async()=>{expect((await page.request.get(baseUrl)).ok()).toBe(true);}); + const openGroups=async(container:any)=>{ + for(const button of await container.getByRole("button",{name:/^\d+ (tool|subagent) calls?$/}).all()) + if(await button.getAttribute("data-state")==="closed")await button.click(); + for(const group of await container.locator("[data-subagent-run-id]").all()){ + const trigger=group.getByRole("button").first(); + if(await trigger.getAttribute("data-state")==="closed")await trigger.click(); + } + const closed=container.locator('.aui-reasoning-root button[data-state="closed"]'); + for(let remaining=await closed.count();remaining>0;remaining--)await closed.first().click(); + }; + const messageFor=(text:string)=>page.locator('[data-slot="aui_assistant-message-root"]').filter({has:page.getByText(text,{exact:true})}).first(); + const verify=async()=>{ + const runtime=messageFor("FINAL_ONCE"); + await expect(page.locator("[data-runtime-output]")).toHaveCount(0); + await expect(runtime.getByText("FINAL_ONCE",{exact:true})).toHaveCount(1); + await openGroups(runtime); + await expect(runtime.getByText(/INTERMEDIATE_ONE/).first()).toBeVisible(); + await expect(runtime.getByText(/EXPLICIT_REASONING/).first()).toBeVisible(); + await expect(runtime.getByText(/INTERMEDIATE_TWO/).first()).toBeVisible(); + await expect(runtime.getByText("CHILD_ANSWER",{exact:true})).toBeVisible(); + await expect(runtime.getByText("A2A_RESULT",{exact:true})).toBeVisible(); + await expect(runtime.locator('[data-token-usage="summary"]')).toContainText("输入 未知 / 输出 未知"); + for(const [name,result] of [["local_tool","LOCAL_RESULT"],["mcp_tool","MCP_RESULT"],["run_skill_script","SKILL_RESULT"]]){ + const button=runtime.getByRole("button",{name:`Used tool: ${name}`,exact:true}); + if(await button.getAttribute("data-state")==="closed")await button.click(); + await expect(runtime.getByText(result,{exact:true})).toBeVisible(); + } + const errorButton=runtime.getByRole("button",{name:"Used tool: failing_tool",exact:true}); + if(await errorButton.getAttribute("data-state")==="closed")await errorButton.click(); + const errorTool=errorButton.locator(".."); + await expect(errorTool.getByText("Error:",{exact:true})).toBeVisible(); + await expect(errorTool.getByText("RECOVERABLE_ERROR",{exact:true}).first()).toBeVisible(); + const first=await runtime.getByText(/INTERMEDIATE_ONE/).first().boundingBox(); + const last=await runtime.getByText(/INTERMEDIATE_TWO/).first().boundingBox(); + expect(first!.y).toBeLessThan(last!.y); + expect(await runtime.locator('.aui-reasoning-root').filter({hasText:"FINAL_ONCE"}).count()).toBe(0); + const legacy=messageFor("LEGACY_ANSWER"); + await openGroups(legacy); + await expect(legacy.locator('.aui-reasoning-root')).toHaveCount(1); + await expect(legacy.getByRole("button",{name:"Used tool: legacy_tool",exact:true})).toBeVisible(); + await expect(runtime.locator('.aui-reasoning-root').first()).toBeVisible(); + return runtime; + }; + const steps=contract.expectedIds("steps"); + await contract.step(steps[0],async()=>{ + await page.goto(`${baseUrl}/zh/newchat?thread_id=101`); + await expect(page.getByText("LEGACY_ANSWER",{exact:true})).toBeVisible(); + await page.getByRole("textbox").fill("Current task"); + await page.getByRole("button",{name:"发送",exact:true}).click(); + await verify(); + }); + await contract.step(steps[1],async()=>{ + addFault("failed");addFault("stopped"); + messages.push({role:"user",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:"Simple task"}); + const simple={schema_version:1,runtime_mode:"native_agent",agent_core:"jiuwen",turn_id:"simple"}; + messages.push({role:"assistant",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:[ + {type:"model_output",content:JSON.stringify({...simple,type:"model_output",sequence:1,block_id:"simple",content:"SIMPLE_FINAL"})}, + {type:"step_observation",content:JSON.stringify({...simple,type:"step_observation",sequence:2,block_id:"simple",content:"",step_number:1,duration:.1})}, + {type:"run_terminal",content:JSON.stringify({...simple,type:"run_terminal",sequence:3,status:"completed",end_reason:"runner_answer",final_content:"SIMPLE_FINAL",final_block_ids:["simple"],content:""})}, + ]}); + messages.push({role:"user",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:"No usage task"}); + const noUsage={schema_version:1,runtime_mode:"native_agent",agent_core:"jiuwen",turn_id:"no-usage"}; + messages.push({role:"assistant",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:[ + {type:"model_output",content:JSON.stringify({...noUsage,type:"model_output",sequence:1,block_id:"no-usage",content:"NO_USAGE_FINAL"})}, + {type:"run_terminal",content:JSON.stringify({...noUsage,type:"run_terminal",sequence:2,status:"completed",final_content:"NO_USAGE_FINAL",final_block_ids:["no-usage"],content:""})}, + ]}); + messages.push({role:"user",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:"Mixed Core task"}); + const childEnvelope={schema_version:1,runtime_mode:"native_agent",agent_core:"jiuwen",turn_id:"mixed-child",invocation_id:"mixed-child"}; + messages.push({role:"assistant",message_id:messages.length+1,message_index:messages.length+1,status:"completed",message:[ + {type:"model_output_thinking",content:"LEGACY_BEFORE_CHILD"}, + {type:"subagent_start",invocation_id:"mixed-child",content:JSON.stringify({invocation_id:"mixed-child",agent_name:"Native failing child",agent_id:99,task:"Child task"})}, + {type:"model_output",invocation_id:"mixed-child",content:JSON.stringify({...childEnvelope,type:"model_output",sequence:1,block_id:"mixed-partial",content:"CHILD_PARTIAL_FAILED"})}, + {type:"run_terminal",invocation_id:"mixed-child",content:JSON.stringify({...childEnvelope,type:"run_terminal",sequence:2,status:"failed",end_reason:"MODEL_STREAM_INCOMPLETE",final_content:"",final_block_ids:[],content:""})}, + {type:"subagent_end",invocation_id:"mixed-child",content:JSON.stringify({invocation_id:"mixed-child"})}, + {type:"final_answer",content:"CODE_PARENT_FINAL"}, + ]}); + fixture.draft.agent_core="smolagents"; + await page.reload();await verify(); + await openGroups(page.locator('[data-slot="aui_assistant-message-root"]')); + for(const partial of ["PARTIAL_failed","PARTIAL_stopped"]){ + const process=page.locator('[data-slot="aui_assistant-message-root"]').filter({hasText:partial}).first(); + await openGroups(process); + await expect(process.getByText(partial,{exact:true})).toBeVisible(); + await expect(process.locator('[data-slot="aui_assistant-message-completion-indicator"]')).toHaveCount(0); + } + await expect(page.getByText("LEGACY_ANSWER",{exact:true})).toBeVisible(); + const simpleMessage=messageFor("SIMPLE_FINAL"); + await expect(simpleMessage.locator(".aui-reasoning-root")).toHaveCount(0); + await expect(simpleMessage.locator('[data-token-usage="summary"]')).toContainText("输入 未知 / 输出 未知"); + await expect(simpleMessage.getByText("SIMPLE_FINAL",{exact:true})).toHaveCount(1); + const noUsageMessage=messageFor("NO_USAGE_FINAL"); + await expect(noUsageMessage.locator('[data-token-usage="summary"]')).toHaveCount(0); + await expect(noUsageMessage.locator(".aui-reasoning-root")).toHaveCount(0); + await expect(page.getByText("CODE_PARENT_FINAL",{exact:true})).toBeVisible(); + const mixedTrigger=page.getByRole("button",{name:"1 subagent call",exact:true}); + if(await mixedTrigger.getAttribute("data-state")==="closed")await mixedTrigger.click(); + const childGroup=page.locator("[data-subagent-run-id]").filter({hasText:"Native failing child"}); + if(await childGroup.getByRole("button").first().getAttribute("data-state")==="closed")await childGroup.getByRole("button").first().click(); + await openGroups(childGroup); + await expect(childGroup.getByText("CHILD_PARTIAL_FAILED",{exact:true})).toBeVisible(); + await expect(childGroup.getByText("执行失败",{exact:true}).first()).toBeVisible(); + await expect(childGroup.getByText("Completed",{exact:true})).toHaveCount(0); + await page.screenshot({path:join(contract.caseDir,"runtime-output.png"),fullPage:true}); + }); + const assertions=contract.expectedIds("assertions"); + await contract.assertion(assertions[0],async()=>{expect(runs).toBe(1);expect(fixture.unexpectedWrites).toEqual([]);}); + await contract.assertion(assertions[1],async()=>{await expect(page.getByText("FINAL_ONCE",{exact:true})).toHaveCount(1);}); +}); diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D5-001/case.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D5-001/case.yaml new file mode 100644 index 0000000000..7c1310f045 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D5-001/case.yaml @@ -0,0 +1,72 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-OUTPUT-D5-001 + feature_id: JIUWEN-OUTPUT + requirement_ids: + - JIUWEN-M5-20261016 + business_rule_ids: + - JIUWEN-OUTPUT-RULE-001 + - JIUWEN-OUTPUT-RULE-002 + - JIUWEN-OUTPUT-RULE-003 + title: Jiuwen output contract and display D5 + type: RELIABILITY + priority: P0 + status: active + automation: automated + objective: Preserve ordered runtime facts and explicit terminal semantics through + streaming, persistence and display. + preconditions: + - Installed Jiuwen and existing local controlled model/resource services; isolated + PostgreSQL for persistence; frontend fixture server for D4. + test_data: + profile: mock + steps: + - order: 1 + action: Inject missing/duplicate Runner terminals, a stream failure after visible + output, or cancellation during real execution. + expected: At most one terminal is emitted, missing/duplicate terminal is failed, + partial is retained and failed/stopped cannot become successful after later + content. + - order: 2 + action: Persist and reload fault output, then run a fresh authorized turn. + expected: Stored and displayed terminal state match SDK outcome, no final answer + is invented, previous tools are not replayed and owned resources drain. + expected_results: + - At most one terminal is emitted, missing/duplicate terminal is failed, partial + is retained and failed/stopped cannot become successful after later content. + - Stored and displayed terminal state match SDK outcome, no final answer is invented, + previous tools are not replayed and owned resources drain. + forbidden_side_effects: + - No inferred success from EOF or text; no duplicate final, lost partial, cross-invocation + tool pairing, invented reasoning or native HTTP bypass. + acceptance: + - id: AC-01 + description: Public output and terminal/display contract preserves execution facts. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 240 + risk_type: RELIABILITY + risk: False success or lost/replayed execution facts on terminal/stream faults. + condition: + - Missing/duplicate terminal, visible partial then stream failure, or explicit cancellation. + metrics: + - Terminal count/status, retained blocks, effect count, owned workers and recovery. + thresholds: + - One terminal at most; zero inferred success or repeated side effects; managed + workers drain. + recovery_expectations: + - New authorized turn completes without executing old tasks. diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D5-001/execution.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D5-001/execution.yaml new file mode 100644 index 0000000000..be56a31b16 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D5-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-OUTPUT-D5-001 +implementations: +- framework: pytest + file: test_output.py + selector: test_output_failure_preserves_facts +timeout_seconds: 300 +acceptance_bindings: + AC-01: + - test_output_failure_preserves_facts +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D5-001/test_output.py b/test-e2e/cases/JIUWEN-OUTPUT-D5-001/test_output.py new file mode 100644 index 0000000000..f19de6f392 --- /dev/null +++ b/test-e2e/cases/JIUWEN-OUTPUT-D5-001/test_output.py @@ -0,0 +1,48 @@ +import asyncio +import json +import sys +from pathlib import Path +import pytest +ROOT=Path(__file__).resolve().parents[3] +sys.path[:0]=[str(ROOT/'sdk'),str(ROOT/'backend'),str(ROOT/'test-e2e/infra/automation')] +from jiuwen_output_runtime import context_database,persist_output +from jiuwen_resource_runtime import resource_provider,run_resources +from jiuwen_collaboration_runtime import run_collaboration +from jiuwen_bridge_runtime import run_worker,stop_infrastructure + +@pytest.fixture(scope="module",autouse=True) +def cleanup(): + yield + stop_infrastructure() + + +pytestmark=[pytest.mark.case_id("JIUWEN-OUTPUT-D5-001"),pytest.mark.stage("D5")] + +@pytest.mark.parametrize("fault",["missing","duplicate","broken_stream","cancel"]) +def test_output_failure_preserves_facts(monkeypatch,tmp_path,fault): + from openjiuwen.core.runner import Runner + original=Runner.run_agent_streaming + if fault in {"missing","duplicate"}: + async def corrupt(*args,**kwargs): + async for chunk in original(*args,**kwargs): + if getattr(chunk,"type",None)=="answer": + if fault=="missing":continue + yield chunk + yield chunk + monkeypatch.setattr(Runner,"run_agent_streaming",corrupt) + with context_database(monkeypatch): + info,state,output=asyncio.run(run_worker(monkeypatch,"text" if fault=="broken_stream" else "native", + "broken_stream" if fault=="broken_stream" else "cancel" if fault=="cancel" else "normal")) + expected="stopped" if fault=="cancel" else "failed" + assert info.attempt_outcome==expected + terminal=[e for e in output if e["type"]=="run_terminal"] + assert len(terminal)==1 and terminal[0]["status"]==expected + assert not terminal[0]["final_content"] and not terminal[0]["final_block_ids"] + if fault in {"broken_stream","cancel"}:assert any(e["type"]=="model_output" and e["content"] for e in output) + assistant,saved,_=asyncio.run(persist_output(monkeypatch,info,output)) + assert assistant["status"]==expected and not assistant["message_content"] + assert any(e["type"]=="run_terminal" and e["status"]==expected for e in saved) + monkeypatch.setattr(Runner,"run_agent_streaming",original) + recovered,state,_=asyncio.run(run_worker(monkeypatch,"native","normal")) + assert recovered.attempt_outcome=="completed" + assert state["tool_calls"]==[(2,3)] diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml new file mode 100644 index 0000000000..7bb89a44f5 --- /dev/null +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml @@ -0,0 +1,100 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: JIUWEN-REAL-UI-D4-001 + feature_id: JIUWEN-OUTPUT + requirement_ids: + - JIUWEN-UNIFIED-OUTPUT-20261016 + - JIUWEN-M4-20261016 + business_rule_ids: + - JIUWEN-OUTPUT-RULE-003 + - JIUWEN-OUTPUT-RULE-004 + - JIUWEN-OUTPUT-RULE-005 + title: Real file-assistant upload and bidirectional Core history in the common UI + type: E2E + priority: P1 + status: active + automation: automated + objective: Prove actual browser upload, real configured model/tool execution, persisted + multi-turn and configuration-side Core switching with the same Nexent rendering. + preconditions: + - Authorized saved file assistant, owned reference image with known visual fact, + configured available real main/vision models, deployed unified Web and runtime; + machine-local fixture identity. + test_data: + profile: real_smoke + locale: zh + viewport: + width: 1440 + height: 1000 + steps: + - order: 1 + action: Clone the saved file assistant with equivalent model, prompts, policies + and tool bindings. Publish smolagents, upload the owned image through the Composer + and ask for actual analysis and a fresh marker, then follow up from saved history. + expected: Real upload and analyze_image occur, successful visual result and one + substantive final are rendered by original components; follow-up recalls the + fact and marker without replaying analysis. + - order: 2 + action: Change the owned assistant Core to Jiuwen in the configuration page, publish, + reopen the same history and continue; upload again as an explicit new task and + follow up. + expected: Original messages and attachments remain unchanged, actual Jiuwen execution + recalls saved facts and marker without old tool replay; new analysis succeeds + and uses the same process/tool/final components. + - order: 3 + action: Switch the owned assistant back to smolagents, publish, reopen and continue + the same history. Reload the finished conversation and compare Core presentation. + expected: Both Core directions continue the same conversation, final content is + not duplicated, persisted display agrees with live display and no Agent run + starts from history navigation alone. For both Cores, live and restored usage + use the same percentage/detail component, with identical model capacity and + configured CM threshold; CM off retains capacity and CM on retains adaptive + mode. + expected_results: + - Real browser uploads and both Core image analyses use configured real models and + return known visual facts, with completed persisted turns. + - Multi-turn and bidirectional configuration changes preserve history and recall + marker/fact without replay on follow-ups; common original UI components render + both Core outputs. Usage presentation retains shared capacity with CM off/on, + and legacy unknown history stays compatible. + forbidden_side_effects: + - No mutation of source assistant or source conversation, no injected model/tool + results or engine request override, no credentials in public output; only owned + fixture objects are cleaned. + - No inferred success from HTTP 200, hidden rerun, lost historical rows, dedicated + runtime renderer, duplicate final or tool execution on follow-up/history navigation. + actor: Authorized Agent development user + journey: + - Upload and analyze with smolagents and follow up. + - Switch configuration to Jiuwen, continue history, analyze a new upload and follow + up. + - Switch back to smolagents and continue, reload and verify common presentation. + final_business_result: + - Actual main/vision model and upload/tool execution with both Cores, preserved + cross-Core history and one common frontend display. + failure_evidence: + - Fixed Playwright assertions, safe wire event summaries, DB status and snapshot + hashes, failure screenshots and local trace. + acceptance: + - id: AC-01 + description: Actual image and model execution through the browser and common rendering. + steps: + - 1 + - 2 + expected_results: + - 1 + forbidden_side_effects: + - 1 + - id: AC-02 + description: Multi-turn and bidirectional Core continuity without replay and preserved + history/UI. + steps: + - 1 + - 2 + - 3 + expected_results: + - 2 + forbidden_side_effects: + - 2 diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml new file mode 100644 index 0000000000..3a07a54796 --- /dev/null +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: JIUWEN-REAL-UI-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: JIUWEN-REAL-UI-D4-001 + profiles: [real_smoke] +timeout_seconds: 1800 +acceptance_bindings: + AC-01: [JIUWEN-REAL-UI-D4-001] + AC-02: [JIUWEN-REAL-UI-D4-001] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py new file mode 100644 index 0000000000..74ec63620f --- /dev/null +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py @@ -0,0 +1,141 @@ +"""Owned deployment fixtures; private state and credentials never enter test output.""" + +import hashlib +import json +import sys +import time +from pathlib import Path + +import httpx +from database.client import get_db_session, minio_client +from database.db_models import AgentInfo, ConversationMessage, ConversationRecord +from utils.auth_utils import generate_session_jwt + + +def digest(value): + return hashlib.sha256(json.dumps(value, default=str, sort_keys=True).encode()).hexdigest() + + +def rows(session, model, **filters): + return [ + {c.name: getattr(row, c.name) for c in model.__table__.columns} + for row in session.query(model).filter_by(**filters).order_by(*model.__table__.primary_key.columns) + ] + + +def checked(response): + assert response.status_code == 200, f"API status {response.status_code} at {response.request.url.path}" + return response.json() + + +def main(): + mode, state_path = sys.argv[1:3] + path = Path(state_path) + if mode == "setup": + source, user, message = int(sys.argv[3]), sys.argv[4], int(sys.argv[5]) + with get_db_session() as session: + image = session.query(ConversationMessage).filter_by(message_id=message).one() + owner = session.query(ConversationRecord).filter_by(conversation_id=image.conversation_id).one() + assert owner.created_by == user, "Image must belong to the authorized user" + files = json.loads(image.minio_files) if isinstance(image.minio_files, str) else image.minio_files + reference = next(f for f in files if f.get("type", "").startswith("image/")) + draft = session.query(AgentInfo).filter_by(agent_id=source, version_no=0, delete_flag="N").one() + published = session.query(AgentInfo).filter_by(agent_id=source, version_no=draft.current_version_no).one() + keys = ["business_logic_model_id", "model_ids", "duty_prompt", "constraint_prompt", "few_shots_prompt", + "enable_context_manager", "context_policy", "max_steps", "requested_output_tokens", + "provide_run_summary", "enable_protocol_repair_retry", "verification_config", "model_params_override"] + values = {k: getattr(published, k) for k in keys} + assert values == {k: getattr(draft, k) for k in keys}, "Draft and published source differ" + state = {"source": source, "user": user, "source_conversation": image.conversation_id, + "source_agent_hash": digest(rows(session, AgentInfo, agent_id=source)), + "source_history_hash": digest(rows(session, ConversationMessage, conversation_id=image.conversation_id)), + "reference_object": reference["object_name"], "model": published.business_logic_model_id, + "source_version": published.version_no, "copied_fields": values, + "access_token": generate_session_jwt(user, expires_in=3600), "expires_at": int(time.time()) + 3600} + path.write_text(json.dumps(state, default=str)) + else: + state = json.loads(path.read_text()) + headers = {"Authorization": "Bearer " + state["access_token"]} + with httpx.Client(base_url="http://nexent-config:5010", headers=headers, trust_env=False, timeout=90) as config, \ + httpx.Client(base_url="http://nexent-runtime:5014", headers=headers, trust_env=False, timeout=90) as runtime: + if mode == "setup": + exported = checked(config.post("/agent/export", json={"agent_id": state["source"]}))["data"] + state["source_export_hash"] = digest(exported) + info = exported["agent_info"][str(state["source"])] + assert not any(v.get("skill_names") for v in exported["agent_info"].values()), "Unexpected source skills need an explicit fixture policy" + suffix = path.stem[-12:] + for key, entry in exported["agent_info"].items(): + entry["name"] = "real_ui_" + suffix + "_" + key + entry["display_name"] = "文件解析助手-真实验收-" + suffix + "-" + key + info["agent_core"] = "smolagents" + result = checked(config.post("/agent/import", json={"agent_info": exported})) + state["agent_id"] = result["agent_id"] + state["owned_agents"] = list(result["agent_id_mapping"].values()) + path.write_text(json.dumps(state, default=str)) + checked(config.post("/agent/update", json={"agent_id": state["agent_id"], **state["copied_fields"], + "agent_core": "smolagents"})) + checked(config.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Real-UI-smol", + "release_note": "Owned real UI validation"})) + cloned = checked(config.post("/agent/export", json={"agent_id": state["agent_id"]}))["data"] + clone_info = cloned["agent_info"][str(state["agent_id"])] + for key in ["tools", "model_ids", "business_logic_model_id", "duty_prompt", "constraint_prompt", "few_shots_prompt"]: + left, right = info.get(key), clone_info.get(key) + if key == "tools": + left = sorted([{k: v for k, v in t.items() if k != "agent_id"} for t in left], key=lambda t: t["name"]) + right = sorted([{k: v for k, v in t.items() if k != "agent_id"} for t in right], key=lambda t: t["name"]) + assert left == right, "Clone configuration mismatch: " + key + state["image_path"] = str(path.with_suffix(".jpg")) + success, _ = minio_client.download_file(state["reference_object"], state["image_path"]) + assert success, "Reference image download failed" + path.write_text(json.dumps(state, default=str)) + print(json.dumps({"setup": True, "source_version": state["source_version"], "same_model_and_tools": True})) + elif mode == "expect-cm": + assert sys.argv[3] in {"true", "false"} + state["copied_fields"]["enable_context_manager"] = sys.argv[3] == "true" + path.write_text(json.dumps(state, default=str)) + print(json.dumps({"cm_expectation_updated": True})) + elif mode == "check": + conversation = int(sys.argv[3]) + with get_db_session() as session: + owner = session.query(ConversationRecord).filter_by(conversation_id=conversation).one() + assert owner.created_by == state["user"] and owner.agent_id == state["agent_id"] + messages = session.query(ConversationMessage).filter_by(conversation_id=conversation).order_by( + ConversationMessage.message_index, ConversationMessage.message_id).all() + draft = session.query(AgentInfo).filter_by(agent_id=state["agent_id"], version_no=0).one() + published = session.query(AgentInfo).filter_by(agent_id=state["agent_id"], version_no=draft.current_version_no).one() + assert all(getattr(published, k) == v for k, v in state["copied_fields"].items()), "Configuration changed beyond Core" + print(json.dumps({"core": published.agent_core, "version": published.version_no, + "messages": [{"id": m.message_id, "index": m.message_index, "role": m.message_role, + "status": m.status, "hash": digest(m.message_content)} for m in messages]})) + elif mode == "cleanup": + with get_db_session() as session: + assert digest(rows(session, AgentInfo, agent_id=state["source"])) == state["source_agent_hash"], "Source assistant changed" + assert digest(rows(session, ConversationMessage, conversation_id=state["source_conversation"])) == state["source_history_hash"], "Source history changed" + owned = session.query(ConversationRecord).filter_by(agent_id=state.get("agent_id"), created_by=state["user"]).all() if state.get("agent_id") else [] + ids = [c.conversation_id for c in owned] + objects = set() + for c in ids: + for m in session.query(ConversationMessage).filter_by(conversation_id=c): + files = json.loads(m.minio_files) if isinstance(m.minio_files, str) else m.minio_files + for f in files or []: + if f.get("object_name") != state["reference_object"]: + objects.add(f["object_name"]) + for obj in objects: + checked(config.delete("/file/storage/" + obj)) + for c in ids: + checked(runtime.delete(f"/conversation/{c}")) + if state.get("source_export_hash"): + assert digest(checked(config.post("/agent/export", json={"agent_id": state["source"]}))["data"]) == state["source_export_hash"], "Source tree configuration changed" + for agent in state.get("owned_agents", [state["agent_id"]] if state.get("agent_id") else []): + checked(config.request("DELETE", "/agent", json={"agent_id": agent})) + Path(state.get("image_path", str(path.with_suffix(".jpg")))).unlink(missing_ok=True) + path.unlink(missing_ok=True) + print(json.dumps({"cleanup": True, "source_unchanged": True, "owned_conversations": len(ids), "uploads": len(objects)})) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print("FIXTURE_FAILURE=" + type(error).__name__ + ": " + str(error)[:180]) + raise SystemExit(1) diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts new file mode 100644 index 0000000000..dfa3976093 --- /dev/null +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts @@ -0,0 +1,222 @@ +import { journey } from "../../infra/automation/d4/runner/journey"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +journey("JIUWEN-REAL-UI-D4-001", async ({ page, contract, expect }) => { + const input = JSON.parse(readFileSync(process.env.NEXENT_REAL_UI_CONFIG!, "utf8")); + const base = process.env.NEXENT_BASE_URL || "http://127.0.0.1:3000"; + const container = input.container || "nexent-runtime"; + const unique = randomUUID().replaceAll("-", "").slice(0, 12); + const target = `/tmp/nexent_real_ui_${unique}`; + const localState = join(contract.caseDir, "private-state.json"); + const localImage = join(contract.caseDir, "reference.jpg"); + const docker = (...args: string[]) => { + try { return execFileSync("docker", args, { encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "pipe"] }); } + catch (error) { + const failure = String((error as any).stdout || "").split(/\r?\n/).find(line => line.startsWith("FIXTURE_FAILURE=")); + throw new Error(failure || "Owned fixture command failed; no successful execution is inferred"); + } + }; + const fixture = (mode: string, ...args: string[]) => { + const output = docker("exec", "-e", "PYTHONPATH=/opt/backend", "-w", "/opt/backend", container, + "/opt/backend/.venv/bin/python", target + ".py", mode, target + ".json", ...args); + return JSON.parse(output.trim().split(/\r?\n/).at(-1)!); + }; + const source = join(process.env.NEXENT_REPO!, "test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py"); + docker("cp", source, container + ":" + target + ".py"); + const outcomes: Record[] = []; + let state: any; + let conversation = 0; + let runCount = 0; + let cmEnabled = false; + let referenceCapacity: any; + let previous: any[] = []; + const imageViews: any[] = []; + const marker = "FILE_UI_" + unique; + const save = () => writeFileSync(join(contract.caseDir, "real-outcomes.json"), JSON.stringify({ profile: "real_smoke", marker, outcomes }, null, 2)); + page.on("request", request => { if (request.method() === "POST" && new URL(request.url()).pathname === "/api/agent/run") runCount++; }); + contract.deferCleanup(async () => { + try { + const cleaned = fixture("cleanup"); + outcomes.push(cleaned); save(); + expect(cleaned.cleanup).toBe(true); expect(cleaned.source_unchanged).toBe(true); + } finally { + for (const path of [localState, localImage]) if (existsSync(path)) unlinkSync(path); + docker("exec", container, "/opt/backend/.venv/bin/python", "-c", `from pathlib import Path; Path('${target}.py').unlink(missing_ok=True)`); + } + }); + await contract.precondition(contract.expectedIds("preconditions")[0], async () => { + const setup = fixture("setup", String(input.source_agent_id), input.user_id, String(input.source_message_id)); + docker("cp", container + ":" + target + ".json", localState); + state = JSON.parse(readFileSync(localState, "utf8")); + docker("cp", container + ":" + state.image_path, localImage); + await page.context().addCookies([ + { name: "nexent_access_token", value: state.access_token, url: base, httpOnly: true, sameSite: "Lax", expires: state.expires_at }, + { name: "nexent_token_expires_at", value: String(state.expires_at), url: base, sameSite: "Lax", expires: state.expires_at }, + ]); + outcomes.push(setup); save(); + return "Authorized equivalent file-assistant clone and owned image are ready; no provider substitution."; + }); + const roots = () => page.locator('[data-slot="aui_assistant-message-root"]'); + const openHistory = async (count: number) => { + const before = runCount; + await page.goto(`${base}/zh/newchat?${conversation ? "thread_id=" + conversation : "agent_id=" + state.agent_id}`); + await expect(page.getByRole("textbox").last()).toBeVisible(); + if (count) await expect(roots()).toHaveCount(count); + expect(runCount).toBe(before); + }; + const openProcess = async (row: any) => { + for (const button of await row.getByRole("button", { name: /^\d+ tool calls?$/ }).all()) + if (await button.getAttribute("data-state") === "closed") await button.click(); + const closed = row.locator('.aui-reasoning-root button[data-state="closed"]'); + for (let count = await closed.count(); count > 0; count--) await closed.first().click(); + }; + const finalTexts = (row: any) => row.locator(".aui-md").evaluateAll((nodes: Element[]) => nodes + .filter(node => !node.closest(".aui-reasoning-root") && !node.closest("[data-subagent-run-id]")) + .map(node => (node as HTMLElement).innerText)); + const send = async (core: string, upload: boolean, query: string) => { + if (upload) { + const uploadButton = page.getByRole("button", { name: /^(添加附件|上传文件)$/ }).first(); + if (!await uploadButton.isVisible()) await page.getByRole("button", { name: "添加", exact: true }).click(); + await expect(uploadButton).toBeVisible(); + const [chooser] = await Promise.all([page.waitForEvent("filechooser"), uploadButton.click()]); + await chooser.setFiles(localImage); + await expect(page.getByText("reference.jpg", { exact: true }).first()).toBeVisible(); + } + await page.getByRole("textbox").last().fill(query); + const wire = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/agent/run", { timeout: 30000 }); + await page.getByRole("button", { name: "发送", exact: true }).click(); + const response = await wire; + expect(response.status()).toBe(200); + const request = response.request().postDataJSON(); + expect(request.agent_id).toBe(state.agent_id); + expect(request.agent_core).toBeUndefined(); + conversation = request.conversation_id ?? Number(response.headers()["conversation_id"]); + expect(conversation).toBeGreaterThan(0); + expect((request.minio_files || []).length).toBeGreaterThanOrEqual(upload ? 1 : 0); + const events = (await response.body()).toString("utf8").split(/\r?\n/) + .filter(line => line.startsWith("data:")).map(line => JSON.parse(line.slice(5).trim())); + expect(events.filter(e => e.type === "error")).toEqual([]); + const terminals = events.filter(e => e.type === "run_terminal" && !e.invocation_id); + if (core === "jiuwen") { expect(terminals).toHaveLength(1); expect(terminals[0].status).toBe("completed"); } + const capacityEvents = core === "jiuwen" + ? events.filter(e => e.type === "step_observation").map(e => e.context_capacity) + : events.filter(e => e.type === "token_count").map(e => JSON.parse(e.content)); + expect(capacityEvents.length).toBeGreaterThan(0); + for (const capacity of capacityEvents) { + expect(capacity.context_window_tokens).toBeGreaterThan(0); + expect(capacity.token_threshold).toBeGreaterThan(0); + expect(capacity.effective_input_limit_tokens).toBeGreaterThan(0); + expect(capacity.context_processing_mode).toBe(cmEnabled ? "adaptive_compact" : "passthrough"); + const values = {window:capacity.context_window_tokens,threshold:capacity.token_threshold,input_limit:capacity.effective_input_limit_tokens}; + if (!referenceCapacity) referenceCapacity=values; + expect(values).toEqual(referenceCapacity); + } + const calls = events.filter(e => e.type === "tool"); + const images = calls.filter(e => e.tool_name === "analyze_image"); + const complete = events.filter(e => e.type === "final_answer" && e.answer_phase === "complete" && !e.invocation_id); + if (core === "smolagents") expect(complete).toHaveLength(1); + const final = core === "jiuwen" ? terminals[0].final_content : complete[0].reclassify_step + ? events.filter(e => e.type === "model_output_thinking" && e.step_id === complete[0].step_id).map(e => e.content).join("") + : events.filter(e => e.type === "final_answer" && e.answer_phase === "delta" && !e.invocation_id).map(e => e.content).join("") || complete[0].content; + outcomes.push({ kind: "wire", core, image_calls: images.length, explicit_complete: true, final_length: final.length }); save(); + expect(final).toContain(marker); + expect(final).toContain(input.expected_fact); + if (upload) { + expect(images).toHaveLength(1); + const results = core === "jiuwen" ? events.filter(e => e.type === "tool_result" && e.local_call_id === images[0].local_call_id) + : events.filter(e => e.type === "execution_logs"); + expect(results.length).toBeGreaterThan(0); + expect(results.some(e => e.is_error === true)).toBe(false); + expect(JSON.stringify(results)).toContain(input.expected_fact); + } else expect(calls.filter(e => !["final_answer", "search_memory"].includes(e.tool_name))).toHaveLength(0); + const number = outcomes.filter(e => e.kind === "turn").length + 1; + await expect(roots()).toHaveCount(number, { timeout: 30000 }); + const row = roots().last(); + await expect(row.locator('[data-slot="aui_assistant-message-completion-indicator"]')).toBeVisible({ timeout: 30000 }); + await expect(page.locator("[data-runtime-output]")).toHaveCount(0); + const texts = await finalTexts(row); + expect(texts).toHaveLength(1); expect(texts[0]).toContain(marker); expect(texts[0]).toContain(input.expected_fact); + await expect(row.locator('[data-token-usage="summary"]')).toHaveCount(0); + const usageButton=row.getByRole("button",{name:/\d+%.*本轮/}); + await expect(usageButton).toBeVisible(); await usageButton.click(); + await expect(row).toContainText(referenceCapacity.window.toLocaleString("en-US")); + await usageButton.click(); + if (upload) { + await openProcess(row); + const tool = row.getByRole("button", { name: "Used tool: analyze_image", exact: true }); + await expect(tool).toBeVisible(); + if (await tool.getAttribute("data-state") === "closed") await tool.click(); + await expect(tool.locator("..")).toContainText(input.expected_fact); + imageViews.push({ core, reasoning: await row.locator(".aui-reasoning-root").count(), tool: await tool.count(), finals: texts.length }); + } + let snapshot: any; + await expect.poll(() => { snapshot = fixture("check", String(conversation)); return snapshot.messages.filter((m: any) => m.role === "assistant" && m.status === "completed").length; }, { timeout: 30000 }).toBe(number); + expect(snapshot.core).toBe(core); + expect(request.version_no).toBe(snapshot.version); + expect(snapshot.messages.map((m: any) => m.index)).toEqual(Array.from({ length: number * 2 }, (_, i) => i)); + expect(snapshot.messages.slice(0, previous.length)).toEqual(previous); previous = snapshot.messages; + outcomes.push({ kind: "turn", core, upload, version: snapshot.version, completed: true, marker_recalled: true, visual_fact: true, + image_calls: images.length, business_tools: calls.map(e => e.tool_name).filter(n => n !== "final_answer"), + message_count: snapshot.messages.length, final_length: final.length, original_rows_unchanged: true, + cm_enabled:cmEnabled,capacity:referenceCapacity,usage_percentage:true }); + save(); + }; + const configure = async (core: string) => { + await page.goto(`${base}/zh/agents/${state.agent_id}`); + const label = page.getByText("Agent Core", { exact: true }); + await expect(label).toBeAttached(); + if (!await label.isVisible()) await page.getByText("高级设置", { exact: true }).click(); + const saved = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/agent/update" + && response.request().postDataJSON()?.agent_core === core); + await page.locator(".ant-select").filter({ has: page.locator("#agent-core") }).click(); + await page.locator(".ant-select-dropdown:visible .ant-select-item-option-content").filter({ hasText: new RegExp(`^${core === "jiuwen" ? "Jiuwen" : "smolagents"}$`) }).click(); + expect((await saved).status()).toBe(200); + const published = await page.request.post(`${base}/api/agent/${state.agent_id}/publish`, { data: { version_name: "Real-UI-" + core + "-" + Date.now(), release_note: "Owned real Core switch" } }); + expect(published.status()).toBe(200); + }; + const enableCM = async () => { + await page.goto(`${base}/zh/agents/${state.agent_id}`); + const label=page.getByText("Agent Core",{exact:true});await expect(label).toBeAttached(); + if (!await label.isVisible()) await page.getByText("高级设置",{exact:true}).click(); + const toggle=page.getByRole("switch",{name:"上下文自动压缩",exact:true}); + await expect(toggle).not.toBeChecked(); + const saved=page.waitForResponse(r=>r.request().method()==="POST"&&new URL(r.url()).pathname==="/api/agent/update"&&r.request().postDataJSON()?.enable_context_manager===true); + await toggle.click();expect((await saved).status()).toBe(200); + fixture("expect-cm","true");cmEnabled=true; + const published=await page.request.post(`${base}/api/agent/${state.agent_id}/publish`,{data:{version_name:"Real-UI-CM-on-"+Date.now(),release_note:"Owned CM validation"}}); + expect(published.status()).toBe(200); + }; + const follow = "只根据本会话之前已经完成的图片分析,衣服主要是什么颜色?同时原样回复先前的验收编号。只用已有历史结果,不要调用任何工具,也不要重新分析图片。"; + const steps = contract.expectedIds("steps"); + await contract.step(steps[0], async () => { + await openHistory(0); + await send("smolagents", true, `请调用图片分析工具分析上传图片,说明人物衣服的主要颜色和画面内容。本会话验收编号是${marker},请记住并在回答末尾原样带上编号。`); + await openHistory(1); await send("smolagents", false, follow); + }); + await contract.step(steps[1], async () => { + await configure("jiuwen"); await openHistory(2); await send("jiuwen", false, follow); + await send("jiuwen", true, `这是新的图片分析任务,请调用图片分析工具分析本次新上传图片,说明人物衣服主要颜色和画面内容,回答末尾仍带上验收编号${marker}。`); + await openHistory(4); await send("jiuwen", false, follow); + }); + await contract.step(steps[2], async () => { + await configure("smolagents"); await openHistory(5); await send("smolagents", false, follow); await openHistory(6); + await configure("jiuwen");await enableCM();await openHistory(6);await send("jiuwen",false,follow); + await configure("smolagents");await openHistory(7);await send("smolagents",false,follow);await openHistory(8); + for (let index = 0; index < 8; index++) { + const text = await finalTexts(roots().nth(index)); + expect(text).toHaveLength(1); expect(text[0]).toContain(marker); expect(text[0]).toContain(input.expected_fact); + const row=roots().nth(index);await expect(row.locator('[data-token-usage="summary"]')).toHaveCount(0); + const usage=row.getByRole("button",{name:/\d+%.*本轮/});await expect(usage).toBeVisible();await usage.click(); + await expect(row).toContainText(referenceCapacity.window.toLocaleString("en-US"));await usage.click(); + } + const signatures = imageViews.map(({ reasoning, tool, finals }) => ({ process: reasoning > 0, tool, finals })); + expect(signatures).toHaveLength(2); expect(signatures[0]).toEqual(signatures[1]); + outcomes.push({ kind: "ui", common_components: true, image_views: imageViews, history_reload: true, run_count: runCount }); save(); + }); + const assertions = contract.expectedIds("assertions"); + await contract.assertion(assertions[0], async () => { expect(outcomes.filter(e => e.kind === "turn" && e.upload)).toHaveLength(2); }); + await contract.assertion(assertions[1], async () => { expect(runCount).toBe(8); expect(previous).toHaveLength(16); await expect(page.locator("[data-runtime-output]")).toHaveCount(0); }); +}); diff --git a/test-e2e/cases/JIUWEN-RECOVERY-D5-001/case.yaml b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/case.yaml new file mode 100644 index 0000000000..5e3c54ec7a --- /dev/null +++ b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/case.yaml @@ -0,0 +1,75 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-RECOVERY-D5-001 + feature_id: JIUWEN-RECOVERY + requirement_ids: + - JIUWEN-M6-20261016 + business_rule_ids: + - JIUWEN-RECOVERY-RULE-001 + - JIUWEN-RECOVERY-RULE-002 + title: Bounded logical recovery over real Jiuwen loop + type: RELIABILITY + priority: P0 + status: active + automation: automated + objective: Recover validated model-step failures within fixed budgets without restarting + the Agent or tools. + preconditions: + - Installed Jiuwen, actual ModelService and controlled loopback model/resource HTTP + services. + test_data: + profile: mock + steps: + - order: 1 + action: Use real Runner, ModelService and local HTTP to inject invalid arguments, + completed empty/reasoning-only/length output and exhausted budgets. Known invalid + execution-code parameters use the same argument-correction quota. + expected: Only allowed logical corrections send another request; per-step and + per-turn limits are enforced independently from physical attempts. + - order: 2 + action: Inject cancellation/deadline/visible transport failure and complete a + fresh turn. + expected: No follow-up after stop/deadline, completed tools execute once and owned + runtime resources drain. + expected_results: + - Only allowed logical corrections send another request; per-step and per-turn limits + are enforced independently from physical attempts. + - No follow-up after stop/deadline, completed tools execute once and owned runtime + resources drain. + forbidden_side_effects: + - No tool replay, unauthorized action, automatic model/Core switch, credential leak + or unbounded retry. + acceptance: + - id: AC-01 + description: Logical budgets, errors and execution ownership remain bounded. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 240 + risk_type: RELIABILITY + risk: Logical and physical retry multiplication or repeated side effects. + condition: + - Invalid/empty/completed length model responses, cancellation and exhausted correction + budgets. + - Recognized nested, syntactically invalid or empty execution-code arguments; persistent + nested blocks exhaust the step quota. + metrics: + - Logical request counts, physical attempts, tools executed and managed cleanup. + thresholds: + - Argument correction <=1 per step and <=3 per turn; response correction <=1 per + turn; no effect replay. + recovery_expectations: + - Fresh independent authorized run succeeds after faults. diff --git a/test-e2e/cases/JIUWEN-RECOVERY-D5-001/execution.yaml b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/execution.yaml new file mode 100644 index 0000000000..5e51f688cf --- /dev/null +++ b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: JIUWEN-RECOVERY-D5-001 +implementations: +- framework: pytest + file: test_recovery.py + selector: "" +timeout_seconds: 300 +acceptance_bindings: + AC-01: + - test_bounded_recovery_without_effect_replay + - test_recovery_respects_remaining_run_budget +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py new file mode 100644 index 0000000000..8095ac85d8 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py @@ -0,0 +1,192 @@ +"""Bounded recovery through the actual Runner, model transport and tool dispatcher.""" + +import asyncio +import json +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT / "sdk"), str(ROOT / "test-e2e/infra/automation")] +import jiuwen_bridge_runtime as bridge + +pytestmark = [pytest.mark.case_id("JIUWEN-RECOVERY-D5-001"), pytest.mark.stage("D5")] + + +@asynccontextmanager +async def provider(scenario, protocol): + state = {"requests": [], "server_errors": [], "tool_calls": [], "first_fragment_no_tool": True} + + async def handle(reader, writer): + try: + header = await reader.readuntil(b"\r\n\r\n") + size = next( + int(line.split(b":", 1)[1]) + for line in header.split(b"\r\n") + if line.lower().startswith(b"content-length:") + ) + body = json.loads(await reader.readexactly(size)) + state["requests"].append(body) + index, effects = len(state["requests"]), len(state["tool_calls"]) + invalid = scenario == "argument_step_limit" or scenario == "argument_turn_limit" and index % 2 == 1 + invalid = invalid or scenario == "arguments_after_tool" and index == 2 + invalid = invalid or scenario == "arguments_once" and index == 1 + response_fault = scenario in {"empty", "reasoning", "length", "response_limit"} and ( + index == 1 or scenario == "response_limit" and index == 2 + ) + content, calls, finish, reasoning = "RECOVERED_FINAL", [], "stop", None + if invalid: + calls = [ + { + "id": "invalid-" + str(index), + "type": "function", + "function": {"name": "add", "arguments": '{"a":"wrong","b":3}'}, + } + ] + content, finish = "", "tool_calls" + elif scenario in {"nested_once", "nested_limit", "invalid_python", "empty_code"} and ( + index == 1 or scenario == "nested_limit" + ): + content = ( + "a=" + if scenario == "invalid_python" + else "" + if scenario == "empty_code" + else "print(1)" + ) + elif response_fault: + content = "PARTIAL" if scenario == "length" else "" + reasoning = "Explicit reasoning" if scenario == "reasoning" else None + finish = "length" if scenario == "length" else "stop" + elif effects == 0 or scenario == "argument_turn_limit": + if protocol == "native": + calls = [ + { + "id": "valid-" + str(index), + "type": "function", + "function": {"name": "add", "arguments": '{"a":2,"b":3}'}, + } + ] + content, finish = "", "tool_calls" + else: + content = "print(add(a=2,b=3))" + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + delta = {"content": content} + if calls: + delta["tool_calls"] = [{**call, "index": i} for i, call in enumerate(calls)] + if reasoning: + delta["reasoning_content"] = reasoning + for value, end in ((delta, None), ({}, finish)): + frame = { + "id": "recovery", + "object": "chat.completion.chunk", + "model": body["model"], + "choices": [{"index": 0, "delta": value, "finish_reason": end}], + } + writer.write(("data: " + json.dumps(frame) + "\n\n").encode()) + await writer.drain() + writer.write(b"data: [DONE]\n\n") + await writer.drain() + except Exception as error: + state["server_errors"].append(type(error).__name__) + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + async with server: + yield "http://127.0.0.1:" + str(server.sockets[0].getsockname()[1]), state + + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + bridge.stop_infrastructure() + + +@pytest.mark.parametrize( + "scenario,protocol,requests,effects,corrections,status", + [ + ("arguments_once", "native", 3, 1, 1, "completed"), + ("arguments_after_tool", "native", 3, 1, 1, "completed"), + ("argument_step_limit", "native", 2, 0, 1, "failed"), + ("argument_turn_limit", "native", 7, 3, 3, "failed"), + ("empty", "native", 3, 1, 1, "completed"), + ("reasoning", "native", 3, 1, 1, "completed"), + ("length", "native", 3, 1, 1, "completed"), + ("response_limit", "native", 2, 0, 1, "failed"), + ("nested_once", "text", 3, 1, 1, "completed"), + ("nested_limit", "text", 2, 0, 1, "failed"), + ("invalid_python", "text", 3, 1, 1, "completed"), + ("empty_code", "text", 3, 1, 1, "completed"), + ("empty", "text", 3, 1, 1, "completed"), + ("length", "text", 3, 1, 1, "completed"), + ], +) +def test_bounded_recovery_without_effect_replay( + monkeypatch, scenario, protocol, requests, effects, corrections, status +): + monkeypatch.setattr(bridge, "provider", provider) + original = bridge.AgentConfig + + def config(**kwargs): + return original(**{**kwargs, "max_steps": 12}) + + monkeypatch.setattr(bridge, "AgentConfig", config) + info, state, events = asyncio.run(bridge.run_worker(monkeypatch, protocol, scenario)) + assert info.attempt_outcome == status + assert len(state["requests"]) == requests + assert len(state["tool_calls"]) == effects + recovery = [ + json.loads(e["content"]) for e in events if e["type"] == "other" and '"logical_recovery"' in e["content"] + ] + assert len(recovery) == corrections + assert all(r["argument_corrections"] <= 3 and r["response_corrections"] <= 1 for r in recovery) + assert len([e for e in events if e["type"] == "run_terminal"]) == 1 + assert info.run_result.status == status + if status == "completed": + assert info.run_result.final_content == "RECOVERED_FINAL" and info.run_result.error is None + else: + assert not info.run_result.final_content + if scenario == "arguments_after_tool": + assert "tool" in [m["role"] for m in state["requests"][-1]["messages"]] + recovered, fresh, _ = asyncio.run(bridge.run_worker(monkeypatch, protocol, "normal")) + assert recovered.attempt_outcome == "completed" and fresh["tool_calls"] == [(2, 3)] + + +@pytest.mark.parametrize("fault", ["cancel", "deadline"]) +def test_recovery_respects_remaining_run_budget(monkeypatch, fault): + import time + + from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + + monkeypatch.setattr(bridge, "provider", provider) + original = JiuwenAdapter._create_model + + def create(adapter): + model = original(adapter) + notify = model.on_recovery + + def stop(details): + notify(details) + if fault == "cancel": + adapter.run_info.stop_event.set() + model.scope.cancel() + else: + model.deadline = time.monotonic() - 1 + + model.on_recovery = stop + return model + + monkeypatch.setattr(JiuwenAdapter, "_create_model", create) + info, state, events = asyncio.run(bridge.run_worker(monkeypatch, "native", "empty")) + assert len(state["requests"]) == 1 and state["tool_calls"] == [] + assert info.attempt_outcome == ("stopped" if fault == "cancel" else "failed") + assert not info.run_result.final_content + if fault == "deadline": + assert info.run_result.error.category == "MODEL_RUN_DEADLINE" + monkeypatch.setattr(JiuwenAdapter, "_create_model", original) + healthy, fresh, _ = asyncio.run(bridge.run_worker(monkeypatch, "native", "normal")) + assert healthy.attempt_outcome == "completed" and fresh["tool_calls"] == [(2, 3)] diff --git a/test-e2e/cases/JIUWEN-RELEASE-D3-001/case.yaml b/test-e2e/cases/JIUWEN-RELEASE-D3-001/case.yaml new file mode 100644 index 0000000000..f3c107723c --- /dev/null +++ b/test-e2e/cases/JIUWEN-RELEASE-D3-001/case.yaml @@ -0,0 +1,72 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-RELEASE-D3-001 + feature_id: JIUWEN-RELEASE + requirement_ids: + - JIUWEN-M6-20261016 + business_rule_ids: + - JIUWEN-RELEASE-RULE-001 + title: Deployed real-model HTTP history and resource smoke + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Prove real provider execution on deployed source and persisted conversation + continuity. + preconditions: + - Current-source deployment, saved native/text real model outlets, authenticated + authorized test tenant; only run-owned temporary agents/conversations/resources + are created. + test_data: + profile: real_smoke + steps: + - order: 1 + action: Execute deployed authenticated HTTP turns, publish configuration-side + Core changes, reload the same conversation and continue. Send empty client history + on follow-up to prove database-owned ordering. + expected: Both Core directions preserve earlier messages and remember the synthetic + marker without re-executing past tasks; snapshot and explicit terminal semantics + are valid. + - order: 2 + action: On the deployed SDK use real saved model outlets and actual local/MCP/Skill/child/A2A + execution bridges with controlled harmless resources. + expected: Required resource effects and results are observed; native/text complete, + subsequent task-only questions do not replay effects, and owned fixtures/workers + are cleaned. + expected_results: + - Both Core directions preserve earlier messages and remember the synthetic marker + without re-executing past tasks; snapshot and explicit terminal semantics are + valid. + - Required resource effects and results are observed; native/text complete, subsequent + task-only questions do not replay effects, and owned fixtures/workers are cleaned. + forbidden_side_effects: + - No original user conversation/config mutation, credential output, model substitution + or false pass from HTTP 200 alone. + acceptance: + - id: AC-01 + description: Real outlet execution and persisted published-Core conversation continuity. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: real_smoke + required_services: + - model-provider + timeout_seconds: 1200 + integration_boundary: + - Deployed config/runtime HTTP and real PostgreSQL; installed deployed SDK/model + service, real provider and controlled harmless resource endpoints. + observations: + - Public events, result status, tool and invocation identities, saved envelopes + and cleanup. + cleanup: + - Delete only run-owned temporary API entities and fixture resources, close managers/clients; + preserve synthetic evidence IDs and sanitized results. diff --git a/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py b/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py new file mode 100644 index 0000000000..73405c79fc --- /dev/null +++ b/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py @@ -0,0 +1,476 @@ +"""Run inside the deployed backend; report only synthetic outcomes, never credentials.""" + +import asyncio +import json +import subprocess +import sys +import tempfile +import threading +import time +import uuid +from pathlib import Path + +import httpx +from database.client import get_db_session +from database.db_models import ConversationMessage, ModelRecord, UserTenant +from nexent.core.agents import nexent_agent +from nexent.core.agents.agent_model import ( + AgentConfig, + AgentHistory, + AgentRunInfo, + ExternalA2AAgentConfig, + ModelConfig, + ToolConfig, +) +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import LanePolicy, ThreadManager +from nexent.core.utils.observer import MessageObserver +from services.memory_config_service import get_memory_switch +from services.model_runtime_snapshot_service import restore_runtime_model_snapshot +from smolagents import Tool +from utils.auth_utils import generate_session_jwt + +NATIVE, TEXT, TENANT = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3] +RESULT = {} + + +def saved_model(model_id, alias): + with get_db_session() as session: + model = session.query(ModelRecord).filter_by(model_id=model_id, tenant_id=TENANT, delete_flag="N").one() + row = {c.name: getattr(model, c.name) for c in ModelRecord.__table__.columns} + profile = restore_runtime_model_snapshot(row, TENANT, strict=True) + return ModelConfig( + cite_name=alias, + model_name=profile.identity.request_model_id, + api_key=model.api_key, + url=profile.connection.base_url, + resolved_profile=profile, + ) + + +def checked(response): + assert response.status_code == 200, "API status " + str(response.status_code) + " at " + response.request.url.path + return response.json() + + +def http_history(): + with get_db_session() as session: + identity = next( + ( + user + for user in session.query(UserTenant).filter_by(tenant_id=TENANT, delete_flag="N") + if not get_memory_switch(user.user_id) + ), + None, + ) + assert identity is not None, "A memory-disabled test identity is required to prove database-only continuity" + token = generate_session_jwt(identity.user_id, expires_in=1800) + headers = {"Authorization": "Bearer " + token} + config = httpx.Client(base_url="http://nexent-config:5010", headers=headers, trust_env=False, timeout=240) + runtime = httpx.Client(base_url="http://nexent-runtime:5014", headers=headers, trust_env=False, timeout=240) + agent, conversation = None, None + marker = "M6_HISTORY_" + uuid.uuid4().hex[:12] + try: + request = { + "name": "m6-" + uuid.uuid4().hex[:8], + "description": "Temporary M6 verification", + "model_ids": [NATIVE], + "business_logic_model_id": NATIVE, + "max_steps": 6, + "agent_core": "smolagents", + "is_main_agent": False, + "provide_run_summary": False, + "enable_context_manager": False, + "enabled": True, + "enabled_tool_ids": [], + "enabled_skill_ids": [], + "related_agent_ids": [], + "duty_prompt": "Answer only the current question. Remember conversation facts. Never repeat old tasks.", + } + agent = checked(config.post("/agent/update", json=request))["agent_id"] + checked( + config.post(f"/agent/{agent}/publish", json={"version_name": "m6-1", "release_note": "Temporary smoke"}) + ) + data = checked(runtime.put("/conversation/create", json={"title": "M6 synthetic history"}))["data"] + conversation = data["conversation_id"] + first_snapshot = None + outcomes = [] + for index, core in enumerate(["smolagents", "jiuwen", "smolagents"]): + if index: + checked(config.post("/agent/update", json={"agent_id": agent, "agent_core": core})) + checked( + config.post( + f"/agent/{agent}/publish", + json={"version_name": "m6-" + str(index + 1), "release_note": "Temporary Core switch"}, + ) + ) + loaded = checked(runtime.get(f"/conversation/{conversation}")) + assert marker in json.dumps(loaded), "History reload lost synthetic marker" + query = ( + "Remember this verification code: " + marker + ". Reply with the code only." + if not index + else ( + "What verification code did I ask you to remember? Reply with that code only; do not repeat old tasks." + ) + ) + response = runtime.post( + "/agent/run", + json={ + "agent_id": agent, + "conversation_id": conversation, + "query": query, + "history": None if index == 0 else [], + "minio_files": [], + }, + ) + assert response.status_code == 200, "Runtime HTTP status" + events = [json.loads(line[5:].strip()) for line in response.text.splitlines() if line.startswith("data:")] + assert not any(e.get("type") == "tool" and e.get("tool_name") != "final_answer" for e in events), ( + "Unexpected tool kinds=" + + repr([(e.get("tool_name"), sorted(e)) for e in events if e.get("type") == "tool"]) + ) + if core == "jiuwen": + terminals = [e for e in events if e.get("type") == "run_terminal" and not e.get("invocation_id")] + assert len(terminals) == 1 and terminals[0]["status"] == "completed", "Root terminal missing" + for _ in range(50): + with get_db_session() as session: + messages = ( + session.query(ConversationMessage) + .filter_by(conversation_id=conversation) + .order_by(ConversationMessage.message_index, ConversationMessage.message_id) + .all() + ) + snapshot = [(m.message_id, m.message_role, m.message_content, m.status) for m in messages] + assert [m.message_index for m in messages] == list(range(len(messages))), "Server order regressed" + assistants = [m for m in snapshot if m[1] == "assistant"] + if len(assistants) == index + 1 and assistants[-1][3] == "completed": + break + time.sleep(0.1) + assert assistants[-1][3] == "completed" and marker in (assistants[-1][2] or ""), ( + "Persisted answer mismatch" + ) + if first_snapshot is None: + first_snapshot = snapshot + else: + assert snapshot[: len(first_snapshot)] == first_snapshot, "Original history changed" + outcomes.append( + { + "core": core, + "completed": True, + "history_loaded": index > 0, + "pre_run_memory_searches": sum(e.get("tool_name") == "search_memory" for e in events), + } + ) + RESULT["deployed_http_history"] = outcomes + finally: + if conversation: + checked(runtime.delete(f"/conversation/{conversation}")) + if agent: + checked(config.request("DELETE", "/agent", json={"agent_id": agent})) + config.close() + runtime.close() + + +async def sdk_resources(directory, url, protocol): + effects = [] + + class M6AddTool: + name, description = "add", "Add two integers." + inputs = {"a": {"type": "integer"}, "b": {"type": "integer"}} + + def forward(self, a, b): + effects.append((a, b)) + return a + b + + nexent_agent.M6AddTool = M6AddTool + skills = directory / protocol / "skills" + skill = skills / "fixture_skill" + (skill / "scripts").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: fixture_skill\ndescription: M6 synthetic skill\n---\nM6_SKILL_GUIDE\n") + effect_file = directory / (protocol + "-effects.txt") + (skill / "scripts/hello.py").write_text( + "from pathlib import Path\np=Path(" + + repr(str(effect_file)) + + ")\np.write_text(p.read_text()+'x' if p.exists() else 'x')\nprint('M6_SCRIPT_OK')\n" + ) + params = { + "local_skills_dir": str(skills), + "authorized_skill_names": ["fixture_skill"], + "isolated_skills_root": True, + } + tools = [ + ToolConfig(name="add", class_name="M6AddTool", source="local", params={}), + ToolConfig(name="multiply", class_name="multiply", source="mcp", params={}), + ToolConfig(name="read_skill_md", class_name="ReadSkillMdTool", source="builtin", params=params), + ToolConfig(name="run_skill_script", class_name="RunSkillScriptTool", source="builtin", params=params), + ] + model = saved_model(NATIVE if protocol == "native" else TEXT, "resource-model") + manager = make_manager() + try: + config = AgentConfig( + agent_core="jiuwen", + name="m6-resource", + description="M6", + model_name=model.cite_name, + max_steps=10, + tools=tools, + ) + info = AgentRunInfo( + query="Execute these actions in order exactly once: add(a=2,b=3), multiply(a=5,b=4), " + "read_skill_md(skill_name='fixture_skill'), run_skill_script(skill_name='fixture_skill', " + "script_path='scripts/hello.py'). You MUST call all four tools. Then reply M6_CHAIN_OK.", + agent_config=config, + model_config_list=[model], + tenant_id=TENANT, + observer=MessageObserver(lang="en"), + stop_event=threading.Event(), + thread_manager=manager, + mcp_host=[url + "/mcp"], + model_deadline=time.monotonic() + 240, + ) + events = [json.loads(e) async for e in agent_run(info, thread_manager=manager)] + assert not model.api_key or model.api_key not in json.dumps(events), "Credential exposed in public events" + assert info.attempt_outcome == "completed", "Resource run failed: " + str(info.run_result.end_reason) + names = [e.get("tool_name") for e in events if e["type"] == "tool"] + assert all(names.count(n) == 1 for n in ("add", "multiply", "read_skill_md", "run_skill_script")), ( + "Resource effects missing or repeated" + ) + assert effects == [(2, 3)] and effect_file.read_text() == "x", "Local/skill effect mismatch" + pairs = {e["tool_name"]: e.get("local_call_id") for e in events if e["type"] == "tool"} + results = { + name: " ".join( + e.get("result", e.get("content", "")) + for e in events + if e["type"] == "tool_result" and e.get("local_call_id") == call_id + ) + for name, call_id in pairs.items() + } + assert "20" in results["multiply"] and "M6_SKILL_GUIDE" in results["read_skill_md"] + assert "M6_SCRIPT_OK" in results["run_skill_script"] + state = httpx.get(url + "/state", trust_env=False).json() + assert state["model_calls"] == 0, "Real provider was replaced by a local model fixture" + before = state["mcp_calls"] + follow = info.model_copy( + update={ + "query": "Reply M6_FOLLOW_OK only. Do not call any tools.", + "history": [ + AgentHistory(role="user", content=info.query), + AgentHistory(role="assistant", content=info.run_result.final_content), + ], + "observer": MessageObserver(lang="en"), + "stop_event": threading.Event(), + "cancellation_scope": None, + "model_deadline": time.monotonic() + 180, + } + ) + _ = [e async for e in agent_run(follow, thread_manager=manager)] + assert follow.attempt_outcome == "completed" and "M6_FOLLOW_OK" in follow.run_result.final_content + assert effects == [(2, 3)] and effect_file.read_text() == "x" + assert httpx.get(url + "/state", trust_env=False).json()["mcp_calls"] == before + RESULT[protocol + "_resources"] = {"tools": names, "completed": True, "followup_no_replay": True} + finally: + drained = await manager.shutdown(timeout=8) + assert not drained.stuck_execution_ids and manager.snapshot().active_count == 0 + + +def make_manager(): + manager = ThreadManager( + "m6-real-smoke", + { + name: LanePolicy(name=name, max_workers=3, max_queue_size=12) + for name in ("agent-run", "model-tool-io", "mcp-session", "sandbox", "control-io", "background-service") + }, + ) + manager.start() + return manager + + +async def collaborators(child_core): + effects, requests = [], [] + + class M6EchoTool(Tool): + name, description, inputs = "child_echo", "Return the M6 child marker.", {} + output_type = "string" + + def forward(self): + effects.append("child") + return "M6_CHILD_OK" + + nexent_agent.M6EchoTool = M6EchoTool + model = saved_model(NATIVE, "parent-model") + child_model = saved_model(TEXT if child_core == "smolagents" else NATIVE, "child-model") + + async def handle(reader, writer): + try: + header = await reader.readuntil(b"\r\n\r\n") + size = next( + int(line.split(b":", 1)[1]) + for line in header.split(b"\r\n") + if line.lower().startswith(b"content-length:") + ) + body = json.loads(await reader.readexactly(size)) + requests.append(body) + output = json.dumps( + { + "jsonrpc": "2.0", + "id": body.get("id"), + "result": {"message": {"role": "agent", "parts": [{"text": "M6_REMOTE_OK"}]}}, + } + ).encode() + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(output)).encode() + + b"\r\nConnection: close\r\n\r\n" + + output + ) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + manager = make_manager() + try: + async with server: + child = AgentConfig( + agent_core=child_core if child_core != "a2a" else "jiuwen", + name="delegate", + invocation_name="delegate", + description="Execute the delegated task", + model_name=child_model.cite_name, + runtime_model_config=child_model, + max_steps=5, + tools=[ToolConfig(name="child_echo", class_name="M6EchoTool", source="local", params={})], + instructions=( + "You must invoke child_echo exactly once and return its marker. " + + ( + "After the observation, respond with M6_CHILD_OK. " + "Never call final_answer() as a Python function and never call child_echo again." + if child_core == "smolagents" + else "" + ) + ), + ) + external = ExternalA2AAgentConfig( + agent_id="m6-remote", + name="delegate", + description="Return remote marker", + url="http://127.0.0.1:" + str(server.sockets[0].getsockname()[1]), + protocol_type="JSONRPC", + timeout=15, + ) + root = AgentConfig( + agent_core="jiuwen", + name="m6-parent", + description="M6", + model_name=model.cite_name, + tools=[], + max_steps=5, + worker_agents=[] if child_core == "a2a" else [child], + external_a2a_agents=[external] if child_core == "a2a" else [], + ) + task = ( + "Return the remote marker" + if child_core == "a2a" + else "Invoke child_echo exactly once and return its marker" + ) + info = AgentRunInfo( + query="You MUST call delegate exactly once with task: " + + task + + ". Reply with the exact marker from delegate. Do not invent the result.", + agent_config=root, + model_config_list=[model], + tenant_id=TENANT, + observer=MessageObserver(lang="en"), + stop_event=threading.Event(), + thread_manager=manager, + model_deadline=time.monotonic() + 240, + collaboration_timeout_seconds=180, + ) + events = [json.loads(e) async for e in agent_run(info, thread_manager=manager)] + assert info.attempt_outcome == "completed", "Collaboration run failed: " + str(info.run_result.end_reason) + expected = "M6_REMOTE_OK" if child_core == "a2a" else "M6_CHILD_OK" + diagnostics = { + "core": child_core, + "effects": len(effects), + "remote_requests": len(requests), + "marker_in_result": expected in info.run_result.final_content, + "tool_result_errors": [e.get("is_error") for e in events if e["type"] == "tool_result"], + "child_terminals": [ + e.get("status") for e in events if e["type"] == "run_terminal" and e.get("invocation_id") + ], + } + assert expected in info.run_result.final_content, "Collaboration marker missing: " + json.dumps( + diagnostics + ) + assert len(requests) == 1 if child_core == "a2a" else effects == ["child"], ( + "Collaboration effect count: " + json.dumps(diagnostics) + ) + assert any(e["type"] == "subagent_start" for e in events), "Child invocation event missing" + RESULT["collaboration_" + child_core] = { + "completed": True, + "effects": len(requests) if child_core == "a2a" else len(effects), + } + finally: + server.close() + await server.wait_closed() + drained = await manager.shutdown(timeout=8) + assert not drained.stuck_execution_ids and manager.snapshot().active_count == 0 + + +def progress(phase): + Path("/tmp/m6-smoke-progress.json").write_text(json.dumps({"phase": phase, "completed": list(RESULT)})) + + +async def main(): + progress("http-history") + http_history() + with tempfile.TemporaryDirectory(prefix="m6-real-") as raw: + directory = Path(raw) + import socket + + with socket.socket() as reserved: + reserved.bind(("127.0.0.1", 0)) + port = reserved.getsockname()[1] + process = subprocess.Popen( + [sys.executable, "/tmp/m6_resource_provider.py", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + url = "http://127.0.0.1:" + str(port) + for _ in range(100): + try: + if httpx.get(url + "/state", trust_env=False, timeout=1).status_code == 200: + break + except httpx.HTTPError: + pass + await asyncio.sleep(0.1) + progress("native-resources") + await sdk_resources(directory, url, "native") + progress("text-resources") + await sdk_resources(directory, url, "text") + for core in ("jiuwen", "smolagents", "a2a"): + progress("collaboration-" + core) + await collaborators(core) + finally: + process.terminate() + process.wait(timeout=10) + import nexent.monitor.monitoring as monitoring + from nexent.core.concurrency.helpers import shutdown_fallback_thread_manager + + if monitoring._monitoring_buffer is not None: + monitoring._monitoring_buffer.stop() + await shutdown_fallback_thread_manager(timeout=8) + progress("completed") + print("SMOKE_RESULT=" + json.dumps(RESULT)) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + detail = str(error)[:180] if isinstance(error, AssertionError) else "See exception category" + print("SMOKE_FAILURE=" + type(error).__name__ + ":" + detail) + print("SMOKE_PARTIAL=" + json.dumps(RESULT)) + raise SystemExit(1) diff --git a/test-e2e/cases/JIUWEN-RELEASE-D3-001/execution.yaml b/test-e2e/cases/JIUWEN-RELEASE-D3-001/execution.yaml new file mode 100644 index 0000000000..5d28998863 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RELEASE-D3-001/execution.yaml @@ -0,0 +1,17 @@ +schema_version: '1.0' +case_id: JIUWEN-RELEASE-D3-001 +implementations: +- framework: pytest + file: test_release.py + selector: test_deployed_real_smoke + profiles: + - real_smoke +timeout_seconds: 1200 +acceptance_bindings: + AC-01: + - test_deployed_real_smoke +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RELEASE-D3-001/test_release.py b/test-e2e/cases/JIUWEN-RELEASE-D3-001/test_release.py new file mode 100644 index 0000000000..a24f7a5ee9 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RELEASE-D3-001/test_release.py @@ -0,0 +1,64 @@ +"""Run the fixed smoke on the authorized deployment with machine-local outlet IDs.""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.case_id("JIUWEN-RELEASE-D3-001"), pytest.mark.stage("D3")] + + +def test_deployed_real_smoke(): + here = Path(__file__).resolve().parent + root = here.parents[2] + container = os.environ.get("NEXENT_REAL_SMOKE_CONTAINER", "nexent-runtime") + values = [ + os.environ.get(key) + for key in ("NEXENT_REAL_NATIVE_MODEL_ID", "NEXENT_REAL_TEXT_MODEL_ID", "NEXENT_REAL_TENANT_ID") + ] + assert all(values), "Real smoke requires machine-local native/text outlet IDs and authorized tenant" + for source, target in [ + (here / "deployed_smoke.py", "/tmp/m6_deployed_smoke.py"), + (root / "test-e2e/infra/automation/jiuwen_resource_provider.py", "/tmp/m6_resource_provider.py"), + ]: + subprocess.run(["docker", "cp", str(source), container + ":" + target], check=True, capture_output=True) + result = subprocess.run( + [ + "docker", + "exec", + "-e", + "PYTHONPATH=/opt/backend", + "-w", + "/opt/backend", + container, + "python", + "/tmp/m6_deployed_smoke.py", + *values, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=1150, + ) + safe = [ + line + for line in result.stdout.splitlines() + if line.startswith(("SMOKE_RESULT=", "SMOKE_FAILURE=", "SMOKE_PARTIAL=")) + ] + print("\n".join(safe)) + return_code = result.returncode + del result + assert return_code == 0, "Deployed real-model smoke failed; sanitized outcomes above" + payload = json.loads(next(line.split("=", 1)[1] for line in safe if line.startswith("SMOKE_RESULT="))) + assert [v["core"] for v in payload["deployed_http_history"]] == ["smolagents", "jiuwen", "smolagents"] + for name in ( + "native_resources", + "text_resources", + "collaboration_jiuwen", + "collaboration_smolagents", + "collaboration_a2a", + ): + assert payload[name]["completed"] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml new file mode 100644 index 0000000000..fc684569b1 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml @@ -0,0 +1,68 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-RESOURCES-D3-001 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-M3-20261015 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-001 + title: Jiuwen resource execution D3 + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Prove real resource protocol execution and lifecycle. + preconditions: + - Installed Jiuwen and ModelService; controlled local model/MCP endpoints and authorized + skill root. + - Docker with the local approved sandbox image for native/text sandbox variants; + fallback to local execution is forbidden. + test_data: + profile: mock + steps: + - order: 1 + action: Execute the resource scenario through the actual factory and controlled + protocol endpoints. + expected: Native and text protocols execute authorized local/MCP/Skills once, + inject hidden caller identity and return actual observations. + - order: 2 + action: Execute the resource scenario through the actual factory and controlled + protocol endpoints. + expected: Follow-up does not replay effects; owned tools, sessions and managed + workers are reclaimed. + expected_results: + - Native and text protocols execute authorized local/MCP/Skills once, inject hidden + caller identity and return actual observations. + - Follow-up does not replay effects; owned tools, sessions and managed workers are + reclaimed. + forbidden_side_effects: + - No unauthorized effects, hidden identity exposure, alternate agent loop, repeated + historical tools or leaked workers. + acceptance: + - id: AC-01 + description: Prove runtime boundary and forbidden effects. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + integration_boundary: + - Nexent factory/worker to Jiuwen to ModelService to real HTTP/SSE to controlled + execution. + observations: + - Provider request capture, execution counter, native events and terminal outcome. + cleanup: + - Release native session/tools and managed threads; close loopback listener and + HTTP response. diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/execution.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/execution.yaml new file mode 100644 index 0000000000..059f9ffcab --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-RESOURCES-D3-001 +implementations: +- framework: pytest + file: test_resources.py + selector: test_resource_protocol_roundtrip +timeout_seconds: 180 +acceptance_bindings: + AC-01: + - test_resource_protocol_roundtrip +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py new file mode 100644 index 0000000000..e094f8f7e7 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py @@ -0,0 +1,42 @@ +"""Real Jiuwen resources through canonical model transport.""" +import asyncio +import json +import sys +from pathlib import Path +import pytest +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT/'sdk'), str(ROOT/'backend'), str(ROOT/'test-e2e/infra/automation')] +from jiuwen_resource_runtime import resource_provider, run_resources +from jiuwen_bridge_runtime import stop_infrastructure +pytestmark = [pytest.mark.case_id("JIUWEN-RESOURCES-D3-001"), pytest.mark.stage("D3")] + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + stop_infrastructure() + +@pytest.mark.parametrize("protocol", ["native", "text"]) +@pytest.mark.parametrize("scenario", ["normal", "parallel", "docker"]) +def test_resource_protocol_roundtrip(monkeypatch, tmp_path, protocol, scenario): + with resource_provider(tmp_path) as url: + info, state, output, calls, effects, elapsed = asyncio.run(run_resources( + monkeypatch, tmp_path, url, protocol, "normal" if scenario == "docker" else scenario, + docker_sandbox=scenario == "docker")) + assert info.attempt_outcome == "completed", output[-1:] + assert calls == [(2,3)] and state["mcp_calls"] == 1 + assert state["user_id"] == "captured-user" + assert effects == ("" if scenario == "parallel" else "x") + assert state["model_calls"] == (2 if scenario == "parallel" else 5) + assert state["saw_skill"] + assert all(bool(body.get("tools")) == (protocol == "native") for body in state["requests"]) + body = json.dumps(state["requests"]) + assert "SECRET_SKILL" not in body and "fixture-only" not in body + assert "forbidden_tool" not in state["tools"] + assert "native_result_20" in str(output) + assert sum(event.get("type") == "final_answer" for event in output) == 1 + if protocol == "native": + multiply = next(tool for tool in state["requests"][0]["tools"] if tool["function"]["name"] == "multiply") + assert "user_id" not in multiply["function"]["parameters"]["properties"] + assert ("fixture_parallel_executor" if scenario == "parallel" else "fixture_multiply") in body + else: + assert "historical_tool_result" in body and "Tool execution error" not in body diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-002/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-002/case.yaml new file mode 100644 index 0000000000..4d30c0a548 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-002/case.yaml @@ -0,0 +1,64 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-RESOURCES-D3-002 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-M3-20261015 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-004 + title: Mixed Core children and A2A D3 + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Prove configured child dispatch and owned remote execution. + preconditions: + - Installed Jiuwen and smolagents; controlled local model HTTP/SSE and A2A endpoints. + test_data: + profile: mock + steps: + - order: 1 + action: Run configured children and A2A through the real factory with controlled + protocol endpoints. + expected: Each child runs its selected Core, isolated model endpoint/options and + authorized resources; remote calls contain trusted caller context without parent + model overrides. + - order: 2 + action: Complete or cancel/stall the nested execution, then run a fresh invocation. + expected: Results and nested observations are balanced; cancellation/deadline + closes owned calls and permits recovery without replay or active workers. + expected_results: + - Each child runs its selected Core, isolated model endpoint/options and authorized + resources; remote calls contain trusted caller context without parent model overrides. + - Results and nested observations are balanced; cancellation/deadline closes owned + calls and permits recovery without replay or active workers. + forbidden_side_effects: + - No parent model/credential/history leakage, unauthorized resources, cross-invocation + state, orphan calls or false reclamation. + acceptance: + - id: AC-01 + description: Prove runtime boundary and forbidden effects. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + integration_boundary: + - Real factory, mixed Core runtime, model HTTP/SSE and A2A HTTP protocol. + observations: + - Provider request capture, execution counter, native events and terminal outcome. + cleanup: + - Release native session/tools and managed threads; close loopback listener and + HTTP response. diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-002/execution.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-002/execution.yaml new file mode 100644 index 0000000000..0a57aef428 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-002/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-RESOURCES-D3-002 +implementations: +- framework: pytest + file: test_collaboration.py + selector: test_configured_collaboration +timeout_seconds: 180 +acceptance_bindings: + AC-01: + - test_configured_collaboration +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-002/test_collaboration.py b/test-e2e/cases/JIUWEN-RESOURCES-D3-002/test_collaboration.py new file mode 100644 index 0000000000..84577e3c66 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-002/test_collaboration.py @@ -0,0 +1,56 @@ +import asyncio +import sys +from pathlib import Path +import pytest +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT/'sdk'), str(ROOT/'backend'), str(ROOT/'test-e2e/infra/automation')] +from jiuwen_collaboration_runtime import run_collaboration +from jiuwen_bridge_runtime import stop_infrastructure + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + stop_infrastructure() + +pytestmark = [pytest.mark.case_id("JIUWEN-RESOURCES-D3-002"), pytest.mark.stage("D3")] + +@pytest.mark.parametrize("parent,child,protocol,remote_protocol", [ + ("jiuwen", "jiuwen", "native", "JSONRPC"), + ("jiuwen", "jiuwen", "text", "JSONRPC"), + ("jiuwen", "smolagents", "native", "JSONRPC"), + ("jiuwen", "smolagents", "text", "JSONRPC"), + ("smolagents", "jiuwen", "native", "JSONRPC"), + ("jiuwen", "a2a", "native", "JSONRPC"), + ("jiuwen", "a2a", "text", "HTTP+JSON"), +]) +def test_configured_collaboration(monkeypatch, parent, child, protocol, remote_protocol): + info, state, output, cores = asyncio.run(run_collaboration(monkeypatch, parent, child, protocol, remote_protocol)) + assert info.attempt_outcome == "completed", output[-2:] + assert len(state["parent"]) == 2 + assert state["parent_effects"] == [] + assert ("parent", parent) in cores + assert state["auth_ok"] and all(state["auth_ok"]) + assert sum(event.get("type") == "subagent_start" for event in output) == 1 + assert sum(event.get("type") == "subagent_end" for event in output) == 1 + start = next(event for event in output if event.get("type") == "subagent_start") + end = next(event for event in output if event.get("type") == "subagent_end") + assert start["invocation_id"] == end["invocation_id"] + assert "CHILD_DONE" in str(output) and "PARENT_DONE" in str(output) + if child == "a2a": + assert len(state["remote"]) == 1 and not state["child"] + body = state["remote"][0] + message = body.get("params", body)["message"] + assert message["metadata"]["user_context"] == {"user_id": "trusted"} + assert message["parts"] == [{"text": "CHILD_TASK_ONLY"}] + assert "model" not in str(body) and "history" not in body + else: + assert ("delegate", child) in cores + assert len(state["child"]) == 2 and not state["remote"] + assert state["effects"] == ["child"] + body = state["child"][0] + assert "parent_only" not in str(body) + assert "child_echo" in str(body) + assert body["temperature"] == 0.2 and state["parent"][0]["temperature"] == 0.9 + assert "PARENT_SYSTEM_ONLY" not in str(body) and "PARENT_TASK_ONLY" not in str(body) + assert "CHILD_TASK_ONLY" in str(body) + assert "parent-only" not in str(body) and "child-only" not in str(body) diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-001/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D5-001/case.yaml new file mode 100644 index 0000000000..058d6ad8c5 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-001/case.yaml @@ -0,0 +1,68 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-RESOURCES-D5-001 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-M3-20261015 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-001 + title: Jiuwen resource execution D5 + type: RELIABILITY + priority: P0 + status: active + automation: automated + objective: Prove real resource protocol execution and lifecycle. + preconditions: + - Installed Jiuwen and ModelService; controlled local model/MCP endpoints and authorized + skill root. + test_data: + profile: mock + steps: + - order: 1 + action: Execute the resource scenario through the actual factory and controlled + protocol endpoints. + expected: MCP stall timeout and cancellation prevent later calls and terminate + within configured grace. + - order: 2 + action: Execute the resource scenario through the actual factory and controlled + protocol endpoints. + expected: One tool worker does not deadlock on nested text-resource calls; subsequent + isolated run completes with zero leaked workers. + expected_results: + - MCP stall timeout and cancellation prevent later calls and terminate within configured + grace. + - One tool worker does not deadlock on nested text-resource calls; subsequent isolated + run completes with zero leaked workers. + forbidden_side_effects: + - No unauthorized effects, hidden identity exposure, alternate agent loop, repeated + historical tools or leaked workers. + acceptance: + - id: AC-01 + description: Prove runtime boundary and forbidden effects. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + risk_type: RELIABILITY + risk: Blocked MCP or nested bounded lane waits leak resources. + condition: + - One model-tool-io worker; MCP timeout or cancellation during a blocked call. + metrics: + - Terminal outcome, subsequent calls, active futures/workers and elapsed close time. + thresholds: + - Exit within 10 seconds; zero leaked workers or calls after stop. + recovery_expectations: + - Subsequent isolated resource run completes. diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-001/execution.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D5-001/execution.yaml new file mode 100644 index 0000000000..01f6b8d920 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-RESOURCES-D5-001 +implementations: +- framework: pytest + file: test_resources.py + selector: test_resource_blocked_mcp_cleanup +timeout_seconds: 180 +acceptance_bindings: + AC-01: + - test_resource_blocked_mcp_cleanup +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-001/test_resources.py b/test-e2e/cases/JIUWEN-RESOURCES-D5-001/test_resources.py new file mode 100644 index 0000000000..d2990ba7eb --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-001/test_resources.py @@ -0,0 +1,30 @@ +"""Real Jiuwen resources through canonical model transport.""" +import asyncio +import json +import sys +from pathlib import Path +import pytest +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT/'sdk'), str(ROOT/'backend'), str(ROOT/'test-e2e/infra/automation')] +from jiuwen_resource_runtime import resource_provider, run_resources +from jiuwen_bridge_runtime import stop_infrastructure +pytestmark = [pytest.mark.case_id("JIUWEN-RESOURCES-D5-001"), pytest.mark.stage("D5")] + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + stop_infrastructure() + +@pytest.mark.parametrize("protocol", ["native", "text"]) +@pytest.mark.parametrize("scenario", ["timeout", "cancel"]) +def test_resource_blocked_mcp_cleanup(monkeypatch, tmp_path, protocol, scenario): + with resource_provider(tmp_path) as url: + info, state, output, calls, effects, elapsed = asyncio.run(run_resources(monkeypatch, tmp_path, url, protocol, scenario)) + assert info.attempt_outcome == ("stopped" if scenario == "cancel" else "failed"), output[-1:] + assert state["mcp_calls"] == 1 and state["model_calls"] == 1 + assert calls == [] and effects == "" + assert elapsed < 10 + # A fresh owner and provider must still complete after cancellation/timeout. + with resource_provider(tmp_path) as url: + restored, _, _, _, effects, _ = asyncio.run(run_resources(monkeypatch, tmp_path, url, protocol)) + assert restored.attempt_outcome == "completed" and effects == "x" diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-002/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D5-002/case.yaml new file mode 100644 index 0000000000..a2b01d7994 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-002/case.yaml @@ -0,0 +1,84 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-RESOURCES-D5-002 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-M3-20261015 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-003 + title: Trusted model snapshot migration and transactional management lifecycle + type: DEPLOYMENT + priority: P0 + status: active + automation: automated + objective: Prove the actual PostgreSQL migration, canonical snapshot persistence + and stale/tenant guards. + preconditions: + - Docker and a locally available PostgreSQL image; an isolated disposable database + with no production data. + test_data: + profile: mock + steps: + - order: 1 + action: Execute migration, management save and runtime restore against an isolated + PostgreSQL database. + expected: Schema upgrade is idempotent and preserves legacy model rows; dry-run + does not persist and applied provisioning can be repeated safely. + - order: 2 + action: Execute migration, management save and runtime restore against an isolated + PostgreSQL database. + expected: Management create/update save canonical snapshots atomically with monotonic + revisions; rollback leaves both row and profile unchanged. Concurrent saves + preserve both edits under the row lock and ordinary saves retain frozen declarations/capabilities + without catalog lookup. + - order: 3 + action: Execute migration, management save and runtime restore against an isolated + PostgreSQL database. + expected: Runtime uses persisted snapshots without catalog lookup, rejects stale/cross-tenant/tampered + rows, and a newly accepted turn sees the updated revision. + expected_results: + - Schema upgrade is idempotent and preserves legacy model rows; dry-run does not + persist and applied provisioning can be repeated safely. + - Management create/update save canonical snapshots atomically with monotonic revisions; + rollback leaves both row and profile unchanged. Concurrent saves preserve both + edits under the row lock and ordinary saves retain frozen declarations/capabilities + without catalog lookup. + - Runtime uses persisted snapshots without catalog lookup, rejects stale/cross-tenant/tampered + rows, and a newly accepted turn sees the updated revision. + forbidden_side_effects: + - No credential in saved snapshot, cross-tenant mutation, partial configuration + commit or writes to an existing application database. + acceptance: + - id: AC-01 + description: Prove persisted trusted outlet lifecycle and isolation. + steps: + - 1 + - 2 + - 3 + expected_results: + - 1 + - 2 + - 3 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + risk_type: DEPLOYMENT + risk: Partial model/profile writes or stale migration snapshots route requests incorrectly. + condition: + - Legacy LLM rows, repeated upgrade/backfill, rejected tenant update and a failed + transaction. + metrics: + - Row count, snapshot revisions, decoded identity and rollback state. + thresholds: + - Zero data loss or partial writes; no cross-tenant update; snapshot contains no + raw credential. + recovery_expectations: + - Retry succeeds; next turn reads the new snapshot. diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-002/execution.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D5-002/execution.yaml new file mode 100644 index 0000000000..9a5437d633 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-002/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-RESOURCES-D5-002 +implementations: +- framework: pytest + file: test_profile_persistence.py + selector: test_trusted_outlet_postgres_lifecycle +timeout_seconds: 180 +acceptance_bindings: + AC-01: + - test_trusted_outlet_postgres_lifecycle +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-002/test_profile_persistence.py b/test-e2e/cases/JIUWEN-RESOURCES-D5-002/test_profile_persistence.py new file mode 100644 index 0000000000..6841cf84f3 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-002/test_profile_persistence.py @@ -0,0 +1,182 @@ +"""Real PostgreSQL migration and management transaction verification in an isolated container.""" + +import asyncio +import json +import subprocess +import sys +import threading +import time +import uuid +from copy import deepcopy +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, select, text +from sqlalchemy.orm import sessionmaker + +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT / "sdk"), str(ROOT / "backend")] + +pytestmark = [pytest.mark.case_id("JIUWEN-RESOURCES-D5-002"), pytest.mark.stage("D5")] + + +def docker(*args): + result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=40, check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + if result.returncode: + raise AssertionError("Isolated PostgreSQL container command failed") + return result.stdout.strip() + + +def test_trusted_outlet_postgres_lifecycle(monkeypatch): + # No application connection settings are used for test writes. + name = "codex-jiuwen-profile-" + uuid.uuid4().hex[:10] + docker("image", "inspect", "postgres:15-alpine", "--format", "{{.Id}}") + docker("run", "-d", "--name", name, "-p", "127.0.0.1::5432", + "-e", "POSTGRES_HOST_AUTH_METHOD=trust", "postgres:15-alpine") + engine = None + try: + port = int(docker("port", name, "5432/tcp").rsplit(":", 1)[1]) + for _ in range(150): + result = subprocess.run(["docker", "exec", name, "pg_isready", "-U", "postgres"], + capture_output=True, timeout=5, check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + if result.returncode == 0: + break + time.sleep(.1) + else: + raise AssertionError("Isolated PostgreSQL did not become ready") + engine = create_engine(f"postgresql://postgres@127.0.0.1:{port}/postgres", echo=False) + from database import client + from database.db_models import ModelRecord + from database.model_management_db import create_model_record as create_legacy + from database.model_management_db import get_model_by_model_id + from database.model_management_db import update_model_record as update_legacy + from database.model_runtime_profile_db import provision_runtime_outlet_batch + from services.model_runtime_provisioning_service import ( + build_runtime_outlet_snapshot, + create_model_record, + update_model_record, + ) + from services.model_runtime_snapshot_service import ( + restore_runtime_model_snapshot, + ) + + monkeypatch.setattr(client.db_client, "session_maker", sessionmaker(bind=engine)) + with engine.begin() as connection: + connection.execute(text("CREATE SCHEMA nexent")) + ModelRecord.__table__.create(engine) + with engine.begin() as connection: + connection.execute(text("ALTER TABLE nexent.model_record_t ALTER COLUMN model_id SET DEFAULT " + "nextval('nexent.model_record_t_model_id_seq')")) + with engine.begin() as connection: + connection.execute(text("ALTER TABLE nexent.model_record_t DROP COLUMN runtime_outlet_snapshot")) + connection.execute(text("INSERT INTO nexent.model_record_t " + "(model_name,model_type,model_repo,display_name,tenant_id,base_url,delete_flag) " + "VALUES ('custom-model','llm','','legacy','tenant','http://127.0.0.1:9999/v1','N')")) + migration = ROOT / "deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql" + ddl = migration.read_text(encoding="utf-8") + with engine.begin() as connection: + connection.exec_driver_sql(ddl) + connection.exec_driver_sql(ddl) + with engine.connect() as connection: + legacy_id = connection.execute(text("SELECT model_id FROM nexent.model_record_t")).scalar_one() + assert connection.execute(text("SELECT count(*) FROM nexent.model_record_t")).scalar_one() == 1 + initial = get_model_by_model_id(legacy_id, "tenant") + assert initial["runtime_outlet_snapshot"] is None + assert provision_runtime_outlet_batch(build_runtime_outlet_snapshot)[0]["status"] == "migrated" + assert get_model_by_model_id(legacy_id, "tenant")["runtime_outlet_snapshot"] is None + assert provision_runtime_outlet_batch(build_runtime_outlet_snapshot, dry_run=False)[0]["status"] == "migrated" + assert provision_runtime_outlet_batch(build_runtime_outlet_snapshot, dry_run=False)[0]["status"] == "ready" + legacy = get_model_by_model_id(legacy_id, "tenant") + assert restore_runtime_model_snapshot(legacy, "tenant").capabilities.tools.status == "unsupported" + + data = {"model_type": "llm", "model_name": "qwen3.5-plus", "model_repo": "", + "display_name": "managed", "model_factory": "alibaba-cn", "api_key": "fixture-credential", + "base_url": "http://127.0.0.1:9999/v1", "max_output_tokens": 512, + "default_output_reserve_tokens": 256, "extra_params": {"enable_thinking": False}} + assert create_model_record(data, "actor", "tenant") is True + with engine.connect() as connection: + managed_id = connection.execute(select(ModelRecord.model_id) + .where(ModelRecord.display_name == "managed")).scalar_one() + before = get_model_by_model_id(managed_id, "tenant") + captured = restore_runtime_model_snapshot(before, "tenant") + assert captured.config_revision == 1 + assert "fixture-credential" not in json.dumps(before["runtime_outlet_snapshot"]) + assert update_model_record(managed_id, {"temperature": .2}, "actor", "other") is False + assert get_model_by_model_id(managed_id, "other") is None + assert update_model_record(managed_id, {"temperature": .2}, "actor", "tenant") is True + after = get_model_by_model_id(managed_id, "tenant") + current = restore_runtime_model_snapshot(after, "tenant") + assert current.config_revision == 2 and current.parameter_values["temperature"] == .2 + assert captured.config_revision == 1 and captured.parameter_values["temperature"] is None + monkeypatch.setattr("services.model_profile_service.get_model_profile_catalog", + lambda: pytest.fail("Saved outlet was reinterpreted against catalog")) + from nexent.core.concurrency import LanePolicy, ManagedTaskSpec, ThreadManager + + entered, release = threading.Event(), threading.Event() + manager = ThreadManager("profile-save-race", { + "model-tool-io": LanePolicy(name="model-tool-io", max_workers=2, max_queue_size=2)}) + manager.start() + + def held_builder(row): + entered.set() + assert release.wait(3) + return build_runtime_outlet_snapshot(row) + + try: + first = manager.submit("model-tool-io", ManagedTaskSpec(task_name="first-save", owner=__name__), + update_legacy, managed_id, {"temperature": .3}, "actor", "tenant", runtime_profile_builder=held_builder) + assert entered.wait(3) + second = manager.submit("model-tool-io", ManagedTaskSpec(task_name="second-save", owner=__name__), + update_model_record, managed_id, {"top_p": .8}, "actor", "tenant") + time.sleep(.05) + assert not second.future.done() + release.set() + assert first.future.result(timeout=3) and second.future.result(timeout=3) + finally: + release.set() + assert not asyncio.run(manager.shutdown(timeout=3)).stuck_execution_ids + concurrent = get_model_by_model_id(managed_id, "tenant") + concurrent_profile = restore_runtime_model_snapshot(concurrent, "tenant") + assert concurrent_profile.config_revision == 4 + assert concurrent_profile.parameter_values["temperature"] == .3 + assert concurrent_profile.parameter_values["top_p"] == .8 + assert concurrent_profile.parameter_rules == captured.parameter_rules + tampered = deepcopy(concurrent["runtime_outlet_snapshot"]) + tampered["row"]["model_id"] += 1000 + with engine.begin() as connection: + connection.execute(ModelRecord.__table__.update().where(ModelRecord.model_id == managed_id) + .values(runtime_outlet_snapshot=tampered)) + with pytest.raises(ValueError, match="IDENTITY_MISMATCH"): + restore_runtime_model_snapshot(get_model_by_model_id(managed_id, "tenant"), "tenant") + with engine.begin() as connection: + connection.execute(ModelRecord.__table__.update().where(ModelRecord.model_id == managed_id) + .values(runtime_outlet_snapshot=concurrent["runtime_outlet_snapshot"])) + + def broken(_row): + raise ValueError("Controlled provisioning failure") + + with pytest.raises(ValueError, match="Controlled provisioning failure"): + create_legacy({**data, "display_name": "must-rollback"}, "actor", "tenant", + runtime_profile_builder=broken) + with pytest.raises(ValueError, match="Controlled provisioning failure"): + update_legacy(managed_id, {"temperature": .9}, "actor", "tenant", runtime_profile_builder=broken) + rolled_back = get_model_by_model_id(managed_id, "tenant") + assert rolled_back["temperature"] == .3 + assert rolled_back["runtime_outlet_snapshot"] == concurrent["runtime_outlet_snapshot"] + with engine.connect() as connection: + assert connection.execute(select(ModelRecord.model_id) + .where(ModelRecord.display_name == "must-rollback")).first() is None + with engine.begin() as connection: + connection.execute(text("UPDATE nexent.model_record_t SET temperature=.5 WHERE model_id=:id"), + {"id": managed_id}) + with pytest.raises(ValueError, match="STALE_SNAPSHOT"): + restore_runtime_model_snapshot(get_model_by_model_id(managed_id, "tenant"), "tenant") + # Management repair restores executable state at the next revision. + assert update_model_record(managed_id, {"temperature": .5}, "actor", "tenant") + assert restore_runtime_model_snapshot(get_model_by_model_id(managed_id, "tenant"), "tenant").config_revision == 5 + finally: + if engine is not None: + engine.dispose() + docker("rm", "-f", name) diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-003/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D5-003/case.yaml new file mode 100644 index 0000000000..1b29256625 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-003/case.yaml @@ -0,0 +1,70 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +case: + case_id: JIUWEN-RESOURCES-D5-003 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-M3-20261015 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-004 + title: Mixed Core children and A2A D5 + type: RELIABILITY + priority: P0 + status: active + automation: automated + objective: Prove configured child dispatch and owned remote execution. + preconditions: + - Installed Jiuwen and smolagents; controlled local model HTTP/SSE and A2A endpoints. + test_data: + profile: mock + steps: + - order: 1 + action: Run configured children and A2A through the real factory with controlled + protocol endpoints. + expected: Each child runs its selected Core, isolated model endpoint/options and + authorized resources; remote calls contain trusted caller context without parent + model overrides. + - order: 2 + action: Complete or cancel/stall the nested execution, then run a fresh invocation. + expected: Results and nested observations are balanced; cancellation/deadline + closes owned calls and permits recovery without replay or active workers. + expected_results: + - Each child runs its selected Core, isolated model endpoint/options and authorized + resources; remote calls contain trusted caller context without parent model overrides. + - Results and nested observations are balanced; cancellation/deadline closes owned + calls and permits recovery without replay or active workers. + forbidden_side_effects: + - No parent model/credential/history leakage, unauthorized resources, cross-invocation + state, orphan calls or false reclamation. + acceptance: + - id: AC-01 + description: Prove runtime boundary and forbidden effects. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + execution: + profiles: + - name: mock + required_services: + - http-fixture + - model-provider + timeout_seconds: 90 + risk_type: RELIABILITY + risk: Child cancellation or A2A stalls leak nested workers and remote calls. + condition: + - One control/model-tool worker and two sandbox workers for two concurrent isolated + kernels; also exhaust sandbox capacity and bound queue wait by the collaboration + deadline. + - Cancel during child model read or cancel/timeout during A2A response. + metrics: + - Terminal outcome, subsequent calls, active futures/workers and elapsed close time. + thresholds: + - Exit within 10 seconds; zero leaked workers or calls after stop. + recovery_expectations: + - Subsequent isolated resource run completes. diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-003/execution.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D5-003/execution.yaml new file mode 100644 index 0000000000..e78621d66c --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-003/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: JIUWEN-RESOURCES-D5-003 +implementations: +- framework: pytest + file: test_collaboration.py + selector: test_nested_stop_and_recovery +timeout_seconds: 180 +acceptance_bindings: + AC-01: + - test_nested_stop_and_recovery +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D5-003/test_collaboration.py b/test-e2e/cases/JIUWEN-RESOURCES-D5-003/test_collaboration.py new file mode 100644 index 0000000000..bd02670ec3 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D5-003/test_collaboration.py @@ -0,0 +1,43 @@ +import asyncio +import sys +from pathlib import Path +import pytest +ROOT = Path(__file__).resolve().parents[3] +sys.path[:0] = [str(ROOT/'sdk'), str(ROOT/'backend'), str(ROOT/'test-e2e/infra/automation')] +from jiuwen_collaboration_runtime import run_collaboration +from jiuwen_bridge_runtime import stop_infrastructure + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + stop_infrastructure() + +import time +pytestmark = [pytest.mark.case_id("JIUWEN-RESOURCES-D5-003"), pytest.mark.stage("D5")] + +@pytest.mark.parametrize("child,fault,protocol", [ + ("jiuwen", "cancel", "native"), ("jiuwen", "cancel", "text"), + ("a2a", "cancel", "native"), ("a2a", "cancel", "text"), + ("jiuwen", "capacity", "text"), + ("a2a", "timeout", "native"), ("a2a", "timeout", "text"), +]) +def test_nested_stop_and_recovery(monkeypatch, child, fault, protocol): + start = time.monotonic() + with monkeypatch.context() as patch: + info, state, output, cores = asyncio.run(run_collaboration(patch, child_core=child, protocol=protocol, fault=fault)) + assert time.monotonic() - start < 10 + if fault != "capacity": + assert state["closed"] + assert state["effects"] == [] and state["parent_effects"] == [] + assert len(state["child"] if child == "jiuwen" else state["remote"]) == 1 + assert sum(event.get("type") == "subagent_start" for event in output) == 1 + assert sum(event.get("type") == "subagent_end" for event in output) == 1 + if fault in {"cancel", "capacity"}: + assert info.attempt_outcome == "stopped" + assert len(state["parent"]) == 1 + else: + assert "Error:" in str(output) + with monkeypatch.context() as patch: + recovered, state, output, cores = asyncio.run(run_collaboration(patch, child_core=child, protocol=protocol)) + assert recovered.attempt_outcome == "completed" and "CHILD_DONE" in str(output) + assert len(state["child"] if child == "jiuwen" else state["remote"]) == (2 if child == "jiuwen" else 1) diff --git a/test-e2e/changes/bugs/JIUWEN-CAPACITY-20261019.yaml b/test-e2e/changes/bugs/JIUWEN-CAPACITY-20261019.yaml new file mode 100644 index 0000000000..c0e847568f --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-CAPACITY-20261019.yaml @@ -0,0 +1,48 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-CAPACITY-20261019 + change_type: bugfix + bug_id: JIUWEN-CAPACITY-20261019 + title: Propagate shared Nexent context capacity through Jiuwen observations + description: Use the existing run-local ContextManager configuration for Jiuwen + capacity and threshold output, including passthrough mode; map public metadata + to the original token_count protocol. + observed_behavior: Jiuwen public steps omit context window and CM thresholds, causing + the common usage component to render counts while smolagents renders a percentage. + expected_behavior: Both Core outputs retain the same configured capacity and effective + CM metadata when CM is enabled or disabled; unknown values remain unknown and + saved history stays compatible. + escaped_stage: production + coverage_gap: + type: missing_assertion + description: The real UI journey checks main components but not shared capacity + metadata or percentage/detail presentation across Core switches. + feature_contract_changed: true + affected_features: + existing: [] + modified: + - JIUWEN-OUTPUT + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-OUTPUT-D5-001 + modified: + - JIUWEN-OUTPUT-D3-001 + - JIUWEN-REAL-UI-D4-001 + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Shared CM capacity emitted on successful/error steps in both processing modes + - Capacity is preserved in realtime and saved Nexent event conversion; legacy + missing data remains unknown + - Invalid public capacity values rejected without inventing usage + reason: Python SDK/public output and frontend normalization behavior change. Python + UT and FE UT are separate from formal runtime/browser proof. + test_selectors: + - test/sdk/core/agents/test_jiuwen_capacity_output.py + notes: Reuse the existing ContextManager and resolved config; no new resolver, Core-specific + frontend renderer or SQL. Strengthen existing D3 and real D4 rather than add duplicate + D1. Prior AIDP 502 and global F-111 reference remain separate failures. diff --git a/test-e2e/changes/bugs/JIUWEN-CURRENT-TASK-20261013.yaml b/test-e2e/changes/bugs/JIUWEN-CURRENT-TASK-20261013.yaml new file mode 100644 index 0000000000..146a93dd3c --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-CURRENT-TASK-20261013.yaml @@ -0,0 +1,25 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-CURRENT-TASK-20261013 + change_type: bugfix + bug_id: JIUWEN-CURRENT-TASK-20261013 + title: Separate historical attachments from the current Jiuwen task and update Core + description: Preserve conversational references without treating old uploads as a new task. + observed_behavior: Later capability and document requests repeat the original image analysis; a transient provider disconnect terminates execution. + expected_behavior: Only current uploads frame the new request; retain authorized historical context, use latest stable Core and retry a transient model failure once without replaying tools. + escaped_stage: D3 + coverage_gap: + type: missing_assertion + description: Existing history probes use no attachments or task changes and do not inject a provider failure after a completed tool call. + feature_contract_changed: false + affected_features: {existing: [], added: [], modified: [], retired: []} + affected_cases: {existing: [], added: [], modified: [], retired: []} + traditional_ut: + decision: add + behaviors: + - Jiuwen current query excludes historical upload framing but retains historical file references and authorization. + - Current uploads remain usable; legacy smolagents framing is unchanged. + - Native instructions distinguish historical results from active requests; transient retry is bounded and does not replay tools. + reason: Backend unit framing and native adapter configuration need isolated regression coverage in addition to actual native HTTP execution. + test_selectors: [test/backend/agents/test_jiuwen_current_task.py, test/sdk/core/agents/test_jiuwen_history.py] + notes: 'Owning SPEC runtime-agent-core-factory AC-009/010/011. Strengthen legacy AGENT-CORE-D3-001, regress legacy D1/D5. Legacy AGENT-CORE-001 assets remain outside canonical index; empty canonical affected lists do not imply no runtime proof. Preserve all IDs and defer migration. Pre-existing global validator blocker F-111-RULE-02 remains separate.' diff --git a/test-e2e/changes/bugs/JIUWEN-HISTORY-20261013.yaml b/test-e2e/changes/bugs/JIUWEN-HISTORY-20261013.yaml new file mode 100644 index 0000000000..97e5d87a5b --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-HISTORY-20261013.yaml @@ -0,0 +1,33 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-HISTORY-20261013 + change_type: bugfix + bug_id: JIUWEN-HISTORY-20261013 + title: Replay conversation history through the native Jiuwen model + description: Normalize Nexent history and native messages before invoking the model. + observed_behavior: The second turn fails with a dict has no attribute role error in the native model client. + expected_behavior: Replay authorized history after system instructions and before the current query, preserving native tool messages and isolated run sessions. + escaped_stage: D3 + coverage_gap: + type: missing_assertion + description: Existing D1 checks compiled history but never submits it to the model; existing D3 uses no history. + feature_contract_changed: false + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + modified: [] + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Native and dictionary message inputs replay history for streaming and non-streaming calls. + - System instructions remain first; history and current query occur once without mutating inputs. + - Empty history and provider failures preserve existing behavior. + reason: Unit conversion proof is separate from the native model HTTP and tool execution boundary. + test_selectors: [test/sdk/core/agents/test_jiuwen_history.py] + notes: 'Reuse runtime-agent-core-factory SPEC AC-002 and legacy owner AGENT-CORE-001. The owner and Cases have not been migrated to the canonical registry: do not invent canonical IDs. Strengthen legacy AGENT-CORE-D3-001 for direct SDK history and authorized ContextInput snapshot history; regress AGENT-CORE-D1-002 and AGENT-CORE-D5-001. Preserve stable IDs and legacy bindings; broader acceptance completeness remains unreviewed. Canonical affected ID lists are empty because these legacy assets are outside its index. Global validation has an existing unrelated AIDP rule-reference blocker; canonical baseline reconciliation is deferred.' diff --git a/test-e2e/changes/bugs/JIUWEN-IMAGE-20261016.yaml b/test-e2e/changes/bugs/JIUWEN-IMAGE-20261016.yaml new file mode 100644 index 0000000000..1151dd1cff --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-IMAGE-20261016.yaml @@ -0,0 +1,50 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-IMAGE-20261016 + change_type: bugfix + bug_id: JIUWEN-IMAGE-20261016 + title: Jiuwen uploaded-image qualification and safe protocol diagnostics + description: Diagnose the reported response failure separately from a confirmed + missing image model binding. + observed_behavior: Uploaded image reaches the workspace but the reported run fails + with an opaque protocol error; independent tool execution also exposes an unconfigured + default image model. + expected_behavior: Use an explicitly configured image model; retain safe protocol + diagnostics without changing public error or retry contracts; image results and + follow-up history are real. + escaped_stage: D3 + coverage_gap: + type: missing_boundary + description: M6 resource fixtures do not validate real uploaded image content + and subsequent conversation continuity. + feature_contract_changed: false + affected_features: + existing: + - JIUWEN-RESOURCES + - JIUWEN-MODEL-BRIDGE + added: [] + modified: [] + retired: [] + affected_cases: + existing: + - JIUWEN-MODEL-BRIDGE-D5-001 + added: + - JIUWEN-IMAGE-D3-001 + modified: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Known adapter categories are logged safely; arbitrary categories and provider + payloads remain private. + - Public error classification and retry flags are unchanged. + - Reuse existing image tool success, empty input and missing-model assertions. + reason: Diagnostics are an isolated normalization responsibility; actual deployed + image capability requires separate D3 proof. + test_selectors: + - test/sdk/core/model_service/test_protocol_diagnostics.py + - test/sdk/core/tools/test_analyze_image_tool.py + notes: Existing runtime-agent-core-factory SPEC updated directly. Original screenshot + protocol cause remains unconfirmed until reproduced; successful configured-image + probes are not a root-cause claim. No new D1; preserve existing cases. Existing + F-111 missing rule blocks global gate. diff --git a/test-e2e/changes/requirements/AGENT-CORE-UI-20261013.yaml b/test-e2e/changes/requirements/AGENT-CORE-UI-20261013.yaml new file mode 100644 index 0000000000..dde4180da2 --- /dev/null +++ b/test-e2e/changes/requirements/AGENT-CORE-UI-20261013.yaml @@ -0,0 +1,33 @@ +schema_version: '1.0' +change: + change_id: AGENT-CORE-UI-20261013 + change_type: requirement + requirement_id: AGENT-CORE-UI-20261013 + title: Expose agent core selection and preserve creation context flags + description: Extend the factory integration with an editable UI and compatible Jiuwen + policies. + affected_features: + existing: [] + added: + - AGENT-CORE-CONFIG + modified: [] + retired: [] + affected_cases: + existing: [] + added: + - AGENT-CORE-UI-D4-001 + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Creation preserves false, true and omitted default flags. + - Core switching preserves resources, clears unsupported settings and child bindings, + respects read-only and recovers from failed save. + reason: Existing service tests omit context flag assertions; isolated policy proof + stays in UT. Browser journey adds real UI/store/payload evidence. + test_selectors: + - test/backend/services/test_agent_core_creation.py + - frontend/tests/component/agentCore.test.tsx + notes: Supplemental deployed HTTP/runtime probes prove creation persistence and + both core runs; preserve legacy factory D1/D3/D5 assets. diff --git a/test-e2e/changes/requirements/JIUWEN-M3-20261015.yaml b/test-e2e/changes/requirements/JIUWEN-M3-20261015.yaml new file mode 100644 index 0000000000..1c20a4f485 --- /dev/null +++ b/test-e2e/changes/requirements/JIUWEN-M3-20261015.yaml @@ -0,0 +1,72 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-M3-20261015 + change_type: requirement + requirement_id: JIUWEN-M3-20261015 + title: Jiuwen M3 resources, lossless Core configuration and trusted outlets + description: Continue revised Jiuwen SPEC; preserve unsupported bindings explicitly. + affected_features: + existing: + - AGENT-CORE-CONFIG + added: + - JIUWEN-RESOURCES + modified: + - AGENT-CORE-CONFIG + retired: [] + affected_cases: + existing: + - JIUWEN-MODEL-BRIDGE-D3-001 + - JIUWEN-MODEL-BRIDGE-D5-001 + added: + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D5-001 + - JIUWEN-RESOURCES-D5-002 + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-RESOURCES-D5-003 + modified: + - AGENT-CORE-UI-D4-001 + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Authorized resource dispatch, schema, hidden identity, async errors and cancellation. + - Core round-trip preserves policies and bindings; failed save rolls back. + - Management snapshot creation/revision, stale/tenant guards and migration failure + isolation. + - Child Core factory dispatch, per-agent model snapshot isolation, invocation + cleanup, A2A per-call ownership and failure propagation. + reason: Existing mapping/parser UT and native-only runtime do not prove text MCP + or saved trusted outlets. Add missing UT and runtime evidence without duplicate + D1. + test_selectors: + - test/backend/services/test_model_runtime_provisioning_service.py + - test/backend/services/test_model_runtime_snapshot_service.py + - test/sdk/core/agents/test_jiuwen_resources.py + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/agents/test_jiuwen_history.py + - test/sdk/core/agents/test_jiuwen_protocol.py + - test/sdk/core/agents/test_run_agent.py + - test/sdk/core/tools/test_parallel_executor.py + - test/backend/agents/test_create_agent_info.py + - test/backend/agents/test_jiuwen_current_task.py + - test/backend/services/test_model_management_service.py + - test/backend/services/test_model_health_service.py + - test/backend/services/test_user_management_service.py + - frontend/tests/component/agentCore.test.tsx + - test/sdk/core/agents/test_agent_model.py + - test/backend/services/test_agent_service.py + - test/sdk/core/models/test_capacity_budget.py + - test/sdk/core/model_service/test_legacy_configuration_export.py + - test/sdk/core/model_service/test_legacy_reasoning_export.py + - test/sdk/core/model_service/test_legacy_invocation_options.py + - test/sdk/core/agents/test_core_child_dispatch.py + - test/backend/agents/test_child_model_binding.py + - test/sdk/core/agents/test_subagent_wrapper.py + - test/sdk/core/agents/test_subagent_invocation_wrapper.py + - test/sdk/core/agents/test_subagent_resource_ownership.py + - test/sdk/core/agents/test_a2a_agent_proxy.py + - test/sdk/core/agents/test_a2a_active_cancellation.py + - test/sdk/core/agents/test_nexent_agent.py + notes: 'Remaining M3 collaboration bridge: reuse wrapper/ownership UT; add mixed + Core and A2A real protocol D3 and cancellation/recovery D5. No duplicate D1; M4 + context compaction remains deferred.' diff --git a/test-e2e/changes/requirements/JIUWEN-M4-20261016.yaml b/test-e2e/changes/requirements/JIUWEN-M4-20261016.yaml new file mode 100644 index 0000000000..1f1d974a99 --- /dev/null +++ b/test-e2e/changes/requirements/JIUWEN-M4-20261016.yaml @@ -0,0 +1,49 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-M4-20261016 + change_type: requirement + requirement_id: JIUWEN-M4-20261016 + title: Jiuwen M4 shared context and persisted Core continuation + description: Configuration page changes Core, then existing conversations reload + and continue; no in-chat Core switch. + affected_features: + existing: + - AGENT-CORE-CONFIG + added: + - JIUWEN-CONTEXT + modified: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-MODEL-BRIDGE-D3-001 + added: + - JIUWEN-CONTEXT-D3-001 + - JIUWEN-CONTEXT-D4-001 + - JIUWEN-CONTEXT-D5-001 + modified: + - AGENT-CORE-UI-D4-001 + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Neutral ContextManager entry retains existing policy and checkpoints without + smolagents memory. + - Summary transport and budgets use saved ModelService profile; errors and cancellation + remain bounded. + - Historical units, attachments and chronology are read-only facts; unknown boundaries + fail closed. + - Saved CM flag is authoritative at the Agent layer; explicit request policy retains precedence. + test_selectors: + - test/sdk/core/agents/test_jiuwen_context.py + - test/sdk/core/agents/test_jiuwen_model_service.py::test_context_summary_accepts_shared_chat_messages_and_validates_completion + - test/backend/agents/test_jiuwen_cm_policy.py + - test/backend/services/test_jiuwen_history_boundary.py + - test/backend/database/test_conversation_history_stopped.py + - test/backend/services/test_agent_service.py::test_prepare_agent_run + reason: Existing CM assembly and history UT cover algorithms, but not Jiuwen consumption + or database-to-mixed-Core continuation. Reuse them and add seam regressions; + no duplicate D1. + notes: D3 uses isolated PostgreSQL and real model protocols; D4 proves configuration-side + changes and old chat loading; D5 proves failed summary, ownership and cancellation. + Existing global missing F-111 rule is not waived. diff --git a/test-e2e/changes/requirements/JIUWEN-M5-20261016.yaml b/test-e2e/changes/requirements/JIUWEN-M5-20261016.yaml new file mode 100644 index 0000000000..2c96eeb05f --- /dev/null +++ b/test-e2e/changes/requirements/JIUWEN-M5-20261016.yaml @@ -0,0 +1,48 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-M5-20261016 + change_type: requirement + requirement_id: JIUWEN-M5-20261016 + title: Jiuwen public output, persistence and display + description: Implement SPEC M5 / AC-017 with minimal shared contracts and existing + SSE/storage/UI adapters. + affected_features: + existing: [] + added: + - JIUWEN-OUTPUT + modified: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-MODEL-BRIDGE-D5-001 + added: + - JIUWEN-OUTPUT-D3-001 + - JIUWEN-OUTPUT-D4-001 + - JIUWEN-OUTPUT-D5-001 + modified: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Validate public observation/result fields and terminal cardinality; preserve + unknown usage and full call IDs. + - Serialize and merge envelopes per block; terminal status and final content require + explicit root result. + - Use one projection for live/history, preserve partial/errors and legacy display; + isolate child identities. + reason: Existing Jiuwen mapping/model and resource tests prove execution, not + ordered public observations or persistence/display. Add seam UT and D3/D4/D5; + no duplicate D1. + test_selectors: + - test/sdk/core/agents/test_jiuwen_output.py + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/agents/test_core_child_dispatch.py + - test/sdk/core/utils/test_observer.py + - test/backend/utils/test_runtime_output.py + - test/backend/services/test_agent_service.py + - frontend/tests/component/runtimeOutput.test.tsx + - frontend/tests/component/subagentRuntimeStatus.test.tsx + notes: M5 does not implement new native/code engines or AC-018 logical recovery. + Existing global F-111 rule blocker remains separate. diff --git a/test-e2e/changes/requirements/JIUWEN-M6-20261016.yaml b/test-e2e/changes/requirements/JIUWEN-M6-20261016.yaml new file mode 100644 index 0000000000..25503579b9 --- /dev/null +++ b/test-e2e/changes/requirements/JIUWEN-M6-20261016.yaml @@ -0,0 +1,53 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-M6-20261016 + change_type: requirement + requirement_id: JIUWEN-M6-20261016 + title: Jiuwen logical recovery and deployed real-model acceptance + description: Complete AC-018 and execute authorized M6 deployment with real model/resource + and history checks. + affected_features: + existing: + - JIUWEN-OUTPUT + - JIUWEN-CONTEXT + added: + - JIUWEN-RECOVERY + - JIUWEN-RELEASE + modified: + - JIUWEN-MODEL-BRIDGE + retired: [] + affected_cases: + existing: + - JIUWEN-OUTPUT-D3-001 + - JIUWEN-OUTPUT-D4-001 + - JIUWEN-OUTPUT-D5-001 + - JIUWEN-CONTEXT-D3-001 + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-CONTEXT-D4-001 + - JIUWEN-RESOURCES-D3-001 + added: + - JIUWEN-RECOVERY-D5-001 + - JIUWEN-RELEASE-D3-001 + modified: + - JIUWEN-MODEL-BRIDGE-D5-001 + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Independent turn/step limits and cancellation/deadline before each correction. + - Preserve partial and classify fatal errors; report recovery without poisoning + a later successful terminal. + - No tool call exposed until fully validated; physical attempts remain ModelService + owned. + reason: Existing model bridge/output UT fail closed but do not prove allowed in-loop + logical recovery. Add budget/mapping UT and one real-loop D5, no duplicate D1. + test_selectors: + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/agents/test_jiuwen_output.py + - test/sdk/core/model_service/test_openai_stream.py + - test/sdk/monitor/test_monitoring_token_totals.py + - test/backend/database/test_conversation_append_order.py + - test/backend/services/test_conversation_runtime_boundary.py + - test/backend/services/test_conversation_management_service.py + notes: M6 deployment evidence and real smoke remain separate from controlled formal + profile; unavailable real prerequisites block acceptance. diff --git a/test-e2e/changes/requirements/JIUWEN-MODEL-BRIDGE-20261015.yaml b/test-e2e/changes/requirements/JIUWEN-MODEL-BRIDGE-20261015.yaml new file mode 100644 index 0000000000..2dcffec6d8 --- /dev/null +++ b/test-e2e/changes/requirements/JIUWEN-MODEL-BRIDGE-20261015.yaml @@ -0,0 +1,51 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-MODEL-BRIDGE-20261015 + change_type: requirement + requirement_id: JIUWEN-MODEL-BRIDGE-20261015 + title: Implement production Jiuwen ModelService bridge and controlled text protocol + description: Implement M1/M2 of the revised runtime-agent-core-factory SPEC; shared + model snapshot management remains an upstream prerequisite. + affected_features: + existing: [] + added: + - JIUWEN-MODEL-BRIDGE + modified: [] + retired: [] + affected_cases: + existing: [] + added: + - JIUWEN-MODEL-BRIDGE-D3-001 + - JIUWEN-MODEL-BRIDGE-D5-001 + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Message/tool/view mapping, detached snapshot and identity validation, truthful + usage and no runtime parameter overrides. + - Strict full-block parsing and authorized code validation; ordinary examples, + malformed/mixed blocks and unknown controls never execute. + - Streaming deltas and complete-message gating, duplicate/missing terminal, normalized + errors, cancellation and generator closure. + reason: Existing history UT patches the old native HTTP Model and legacy formal + runtime has no shared ModelService or text-protocol execution; retain old D1 + and strengthen appropriate runtime proof, without duplicate new D1. + test_selectors: + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/agents/test_jiuwen_protocol.py + - test/sdk/core/agents/test_jiuwen_history.py + - test/backend/services/test_model_runtime_snapshot_service.py + - test/sdk/core/agents/test_run_agent.py + - test/sdk/core/agents/test_agent_model.py + - test/backend/agents/test_create_agent_info.py + - test/sdk/core/model_service/test_http_contracts.py + - test/sdk/core/model_service/test_http_sender.py + - test/sdk/core/model_service/test_model_service.py + - test/sdk/core/model_service/test_openai_chat_adapter.py + - test/sdk/core/model_service/test_openai_stream.py + - test/sdk/core/model_service/test_outlet_storage.py + - test/sdk/core/model_service/test_resource_conversion.py + notes: AC-012/014 and relevant AC-018/019 portions only. D2/D4 excluded for unchanged + HTTP/UI; D3 tests real transport/loop and D5 tests lifecycle/recovery. No claim + of CM/resource/history/display completion. diff --git a/test-e2e/changes/requirements/JIUWEN-UNIFIED-OUTPUT-20261016.yaml b/test-e2e/changes/requirements/JIUWEN-UNIFIED-OUTPUT-20261016.yaml new file mode 100644 index 0000000000..1c561526f5 --- /dev/null +++ b/test-e2e/changes/requirements/JIUWEN-UNIFIED-OUTPUT-20261016.yaml @@ -0,0 +1,34 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-UNIFIED-OUTPUT-20261016 + change_type: requirement + requirement_id: JIUWEN-UNIFIED-OUTPUT-20261016 + title: Normalize Core outputs into the existing Nexent conversation UI + description: Replace the M5 dedicated runtime renderer with a shared output protocol + adapter consumed by the original live/history parsers. + affected_features: + existing: [] + modified: + - JIUWEN-OUTPUT + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-OUTPUT-D3-001 + - JIUWEN-OUTPUT-D5-001 + modified: + - JIUWEN-OUTPUT-D4-001 + added: [] + retired: [] + traditional_ut: + decision: exempt + behaviors: + - Frontend unit tests cover output normalization, lifecycle safety, error correlation, + legacy and mixed-Core compatibility. + reason: Frontend-only product change; no Python product behavior changes. Relevant + frontend unit tests are mandatory and separate from formal browser proof. + test_selectors: [] + notes: Existing M5 SPEC directly revised. Preserve all business assertions in D4, + replacing dedicated runtime DOM with original shared components. No new D1; existing + Python D3/D5 regress public SDK/backend contracts. Prior global F-111 missing + reference remains independent. diff --git a/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml b/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml new file mode 100644 index 0000000000..3330ad69e0 --- /dev/null +++ b/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml @@ -0,0 +1,24 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-REAL-UI-20261019 + change_type: test-fix + title: Qualify real file-assistant uploads and cross-Core chat presentation + description: Add missing real-browser proof beyond the controlled API display journey + and deployed HTTP image fixture. No product behavior change. + affected_features: + existing: + - JIUWEN-OUTPUT + modified: [] + added: [] + retired: [] + affected_cases: + existing: [] + modified: [] + added: + - JIUWEN-REAL-UI-D4-001 + retired: [] + notes: Existing IMAGE-D3 proves an existing object over HTTP; OUTPUT-D4 and CONTEXT-D4 + use controlled APIs. None prove a real Composer upload plus actual model/tool + calls and config-side published switching together. New D4 proves this user journey; + no duplicate D1/UT, no product code change. Global pre-existing F-111 reference + is an independent blocker. diff --git a/test-e2e/features/AGENT-CORE-CONFIG/feature.yaml b/test-e2e/features/AGENT-CORE-CONFIG/feature.yaml new file mode 100644 index 0000000000..150e50ef7b --- /dev/null +++ b/test-e2e/features/AGENT-CORE-CONFIG/feature.yaml @@ -0,0 +1,26 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core configuration +feature: + feature_id: AGENT-CORE-CONFIG + name: Selectable agent core configuration + status: active + description: Select an agent core while preserving supported resources and enforcing + unsupported configuration constraints. + business_rules: + - rule_id: AGENT-CORE-CONFIG-RULE-001 + description: Core defaults to smolagents, persists through saves/reloads and cannot + be changed in read-only mode. + - rule_id: AGENT-CORE-CONFIG-RULE-002 + description: Core selection preserves all policies and bindings; unsupported controls + are disabled without changing stored values. + - rule_id: AGENT-CORE-CONFIG-RULE-003 + description: Creation preserves explicit false or true context manager flags and + omitted true default. + required_test_stages: + - D4 + source: + requirement_ids: + - AGENT-CORE-UI-20261013 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/features/JIUWEN-CONTEXT/feature.yaml b/test-e2e/features/JIUWEN-CONTEXT/feature.yaml new file mode 100644 index 0000000000..b2a1e85fb4 --- /dev/null +++ b/test-e2e/features/JIUWEN-CONTEXT/feature.yaml @@ -0,0 +1,29 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core runtime +feature: + feature_id: JIUWEN-CONTEXT + name: Jiuwen shared context and persisted conversation continuation + status: active + description: Saved CM policy, shared checkpoints and old conversations after configuration-side + Core changes. + business_rules: + - rule_id: JIUWEN-CONTEXT-RULE-001 + description: Jiuwen consumes existing ContextManager selection, budgets and summary + checkpoints through ModelService without native compaction or fabricated AgentMemory. + - rule_id: JIUWEN-CONTEXT-RULE-002 + description: Existing conversation database facts load after configuration-side + Core changes in both directions; current input occurs once and completed tasks + are never replayed. + - rule_id: JIUWEN-CONTEXT-RULE-003 + description: Failed or cancelled summarization cannot overwrite valid checkpoints, + leak another owner history or execute historical tools. + required_test_stages: + - D3 + - D4 + - D5 + source: + requirement_ids: + - JIUWEN-M4-20261016 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/features/JIUWEN-MODEL-BRIDGE/feature.yaml b/test-e2e/features/JIUWEN-MODEL-BRIDGE/feature.yaml new file mode 100644 index 0000000000..345b6b4bb8 --- /dev/null +++ b/test-e2e/features/JIUWEN-MODEL-BRIDGE/feature.yaml @@ -0,0 +1,31 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core runtime +feature: + feature_id: JIUWEN-MODEL-BRIDGE + name: Jiuwen unified model bridge and controlled execution protocol + status: active + description: M1/M2 integration with shared model service; no generic NativeAgent + or CodeAgent implementation. + business_rules: + - rule_id: JIUWEN-MODEL-BRIDGE-RULE-001 + description: Jiuwen uses an injected detached trusted outlet snapshot and the + canonical ModelService only; complete normalized responses control execution. + - rule_id: JIUWEN-MODEL-BRIDGE-RULE-002 + description: Native or registered controlled-text protocol is fixed per run; complete + authorized calls execute once and actual results return to the same Jiuwen loop. + - rule_id: JIUWEN-MODEL-BRIDGE-RULE-003 + description: Partial streams, invalid responses and cancellation fail without + incomplete tool execution, retries multiplying or resource leaks. + - rule_id: JIUWEN-MODEL-BRIDGE-RULE-004 + description: Identical repeated stream usage is idempotent and counted once; conflicting + usage still fails without replay. + required_test_stages: + - D3 + - D5 + source: + requirement_ids: + - JIUWEN-MODEL-BRIDGE-20261015 + - JIUWEN-M6-20261016 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/features/JIUWEN-OUTPUT/feature.yaml b/test-e2e/features/JIUWEN-OUTPUT/feature.yaml new file mode 100644 index 0000000000..5fa5d1e523 --- /dev/null +++ b/test-e2e/features/JIUWEN-OUTPUT/feature.yaml @@ -0,0 +1,37 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core runtime +feature: + feature_id: JIUWEN-OUTPUT + name: Jiuwen public observations and persisted runtime display + status: active + description: Ordered public observations, explicit terminal results and identical + live/history display. + business_rules: + - rule_id: JIUWEN-OUTPUT-RULE-001 + description: Content, reasoning, tools and exact results retain ordered blocks + and call/invocation identities through public StepObservation and RunResult. + - rule_id: JIUWEN-OUTPUT-RULE-002 + description: Only an explicit terminal confirms final blocks once; failure, cancellation + and missing terminal preserve partial and never imply success. + - rule_id: JIUWEN-OUTPUT-RULE-003 + description: Realtime and persisted history use the same envelope projection for + tools, MCP, Skills, children and A2A; legacy Code display remains compatible. + - rule_id: JIUWEN-OUTPUT-RULE-004 + description: All Core outputs normalize at a shared protocol adapter into the + existing Nexent message types; live and saved messages use the same parser and + UI components without Core-specific rendering. + - rule_id: JIUWEN-OUTPUT-RULE-005 + description: All Core usage output takes context capacity and CM limits from the + same resolved Nexent configuration, even in passthrough mode; absent capacity + stays unknown and does not inherit another run. + required_test_stages: + - D3 + - D4 + - D5 + source: + requirement_ids: + - JIUWEN-M5-20261016 + - JIUWEN-UNIFIED-OUTPUT-20261016 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/features/JIUWEN-RECOVERY/feature.yaml b/test-e2e/features/JIUWEN-RECOVERY/feature.yaml new file mode 100644 index 0000000000..719c2e0910 --- /dev/null +++ b/test-e2e/features/JIUWEN-RECOVERY/feature.yaml @@ -0,0 +1,25 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core runtime +feature: + feature_id: JIUWEN-RECOVERY + name: Jiuwen bounded logical recovery + status: active + description: Separate bounded logical correction from ModelService physical attempts + without replaying effects. + business_rules: + - rule_id: JIUWEN-RECOVERY-RULE-001 + description: Correct invalid arguments once per model step and three times per + turn; recover empty/reasoning-only/length completion once per turn. Preserve + deadline, cancellation and explicit request identities. + - rule_id: JIUWEN-RECOVERY-RULE-002 + description: Never retry unknown control, authorization, transport partial or + tool execution failures; no tool replay, model switch or multiplied transport + retries. + required_test_stages: + - D5 + source: + requirement_ids: + - JIUWEN-M6-20261016 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/features/JIUWEN-RELEASE/feature.yaml b/test-e2e/features/JIUWEN-RELEASE/feature.yaml new file mode 100644 index 0000000000..80941631b9 --- /dev/null +++ b/test-e2e/features/JIUWEN-RELEASE/feature.yaml @@ -0,0 +1,21 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core runtime +feature: + feature_id: JIUWEN-RELEASE + name: Deployed Jiuwen real-model qualification + status: active + description: Distinguish deployed HTTP/history proof and real-model resource execution + from controlled provider tests. + business_rules: + - rule_id: JIUWEN-RELEASE-RULE-001 + description: Build current source, preserve storage and rollback identities; real + provider runs cover each resource and multiple turns including published Core + changes with saved database history. + required_test_stages: + - D3 + source: + requirement_ids: + - JIUWEN-M6-20261016 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/features/JIUWEN-RESOURCES/feature.yaml b/test-e2e/features/JIUWEN-RESOURCES/feature.yaml new file mode 100644 index 0000000000..07a00aebb8 --- /dev/null +++ b/test-e2e/features/JIUWEN-RESOURCES/feature.yaml @@ -0,0 +1,31 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core runtime +feature: + feature_id: JIUWEN-RESOURCES + name: Jiuwen shared resource execution and trusted outlet onboarding + status: active + description: M3 resource bridge, lossless core selection and management-side trusted + snapshots. + business_rules: + - rule_id: JIUWEN-RESOURCES-RULE-001 + description: Native and text calls share authorized tool/MCP/Skills execution, + identity, observations and cleanup. + - rule_id: JIUWEN-RESOURCES-RULE-002 + description: Core selection preserves saved policies and resource bindings; unsupported + capabilities fail explicitly. + - rule_id: JIUWEN-RESOURCES-RULE-003 + description: Management and migration persist trusted versioned snapshots; runtime + restores without catalog and rejects stale/cross-tenant profiles. + - rule_id: JIUWEN-RESOURCES-RULE-004 + description: Each child invocation selects its own Core and frozen model outlet/options; + A2A uses its configured remote protocol, authenticated context, deadline and + cancellation ownership. + required_test_stages: + - D3 + - D5 + source: + requirement_ids: + - JIUWEN-M3-20261015 + spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/infra/automation/jiuwen_bridge_fixture.py b/test-e2e/infra/automation/jiuwen_bridge_fixture.py new file mode 100644 index 0000000000..fc8def89bb --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_bridge_fixture.py @@ -0,0 +1,146 @@ +"""Controlled real HTTP/SSE fixtures for Jiuwen model bridge runtime cases.""" + +import asyncio +import json +from contextlib import asynccontextmanager + +from nexent.core.model_service.profiles.composition import compose_provider_model_policy +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_resolution import construct_resolved_model_profile +from nexent.core.model_service.profiles.resolved_profile import ConnectionRules, ContextConfiguration, OutletConnection +from nexent.core.model_service.profiles.resolver import load_profile_catalog +from nexent.core.model_service.registry import model_service_references + + +def profile_for(url, protocol): + """Construct a real resolved profile using the upstream public catalog.""" + registry = dict(model_service_references()) + catalog = load_profile_catalog() + bindings = {key: registry[key][0].model_dump() for key in ( + "protocol", "auth", "adapter", "code_converter", "native_converter", + )} + policy = compose_provider_model_policy( + catalog, provider_key="alibaba-cn", request_model_id="qwen3.5-plus", model_profile_key=None, + base_bindings=bindings, base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=registry, submitted_values={"enable_thinking": False}, + ) + profile = construct_resolved_model_profile( + policy, catalog, tenant_id="spike-tenant", display_name="spike-outlet", model_type="llm", + provider_id="alibaba", connection=OutletConnection( + base_url=url + "/v1", ssl_verify=True, timeout_seconds=5, concurrency_limit=None, + credential_ref={"kind": "model_record", "model_id": None}, + ), connection_rules=ConnectionRules( + credential_kind="model_record", relative_path="/chat/completions", method="POST", headers={}, + ), context_configuration=ContextConfiguration( + output_reserve_tokens=None, tokenizer_family=None, capacity_source="profile", + capability_profile_version=None, + ), registered_references=registry, + ) + data = profile.model_dump() + data["outlet_id"] = 7 + data["connection"]["credential_ref"]["model_id"] = 7 + if protocol != "native": + data["capabilities"]["tools"]["status"] = "unsupported" if protocol == "text" else "unknown" + return type(profile).model_validate(data) + + +@asynccontextmanager +async def provider(scenario, protocol): + """Real local HTTP/SSE, single event loop, no external credentials or threads.""" + state = {"requests": [], "server_errors": [], "first_fragment_no_tool": None, "tool_calls": []} + + async def handle(reader, writer): + try: + headers = await reader.readuntil(b"\r\n\r\n") + size = int(next(line.split(b":", 1)[1] for line in headers.split(b"\r\n") + if line.lower().startswith(b"content-length:"))) + body = json.loads(await reader.readexactly(size)) + state["requests"].append(body) + first = (len(state["requests"]) == 1 or scenario == "bad_args" + or (scenario == "transient" and len(state["requests"]) == 2)) + if (scenario == "unavailable" or scenario == "transient" and len(state["requests"]) == 1 + or scenario == "transient_after_tool" and len(state["requests"]) == 2): + writer.write(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + await writer.drain() + return + if scenario == "auth": + payload = json.dumps({"error": {"message": "fixture authentication failure", "type": "auth"}}).encode() + writer.write(b"HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(payload)).encode() + b"\r\nConnection: close\r\n\r\n" + payload) + await writer.drain() + return + call = {"id": "provider-call-1", "type": "function", "function": { + "name": "add", "arguments": '{"a":2,"b":3}' if scenario != "bad_args" else '{"a":', + }} + if scenario == "cancel": + content, calls = "PARTIAL_BEFORE_CANCEL", [] + elif scenario == "plain": + content, calls = "Example only: ```python\nadd(2, 3)\n```", [] + elif protocol != "native" and first: + content = "\nresult = add(a=2, b=3)\nprint(result)\n" + if scenario == "truncated": + content = "\nadd(a=2, b=3)" + elif scenario == "ambiguous": + content += "\nadd(a=2,b=3)" + calls = [] + elif protocol == "native" and first: + content, calls = "", [call] + else: + content, calls = "SPIKE_FINAL_RESULT_5", [] + finish = "tool_calls" if calls else "stop" + if scenario == "unknown_finish": + finish = "mystery_control" + if body.get("stream"): + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + async def frame(delta, finish_reason=None): + value = {"id": "fixture-response", "object": "chat.completion.chunk", "model": body["model"], + "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}]} + if scenario == "duplicate_usage" and finish_reason: + value["usage"] = {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5} + writer.write(("data: " + json.dumps(value) + "\n\n").encode()) + await writer.drain() + if calls: + await frame({"tool_calls": [{"index": 0, "id": call["id"], "type": "function", "function": { + "name": "add", "arguments": '{"a":', + }}]}) + await asyncio.sleep(0.02) + state["first_fragment_no_tool"] = not state["tool_calls"] + if scenario == "broken_stream": + return + await frame({"tool_calls": [{"index": 0, "function": { + "arguments": '2,"b":3}' if scenario != "bad_args" else "", + }}]}) + else: + half = len(content) // 2 + await frame({"content": content[:half]}) + await asyncio.sleep(0.02) + state["first_fragment_no_tool"] = not state["tool_calls"] if first else state["first_fragment_no_tool"] + if scenario == "broken_stream": + return + if scenario == "cancel": + await reader.read() + state["cancel_response_closed"] = True + return + await frame({"content": content[half:]}) + await frame({}, finish) + if scenario == "duplicate_usage": + value = {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}} + writer.write(("data: " + json.dumps(value) + "\n\n").encode()) + writer.write(b"data: [DONE]\n\n") + await writer.drain() + else: + payload = json.dumps({"id": "fixture-response", "object": "chat.completion", "model": body["model"], + "choices": [{"index": 0, "message": {"role": "assistant", "content": content, + "tool_calls": calls}, "finish_reason": finish}]}).encode() + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(payload)).encode() + b"\r\nConnection: close\r\n\r\n" + payload) + await writer.drain() + except Exception as exc: + state["server_errors"].append(type(exc).__name__) + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + async with server: + yield f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}", state diff --git a/test-e2e/infra/automation/jiuwen_bridge_runtime.py b/test-e2e/infra/automation/jiuwen_bridge_runtime.py new file mode 100644 index 0000000000..a6b5de8a99 --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_bridge_runtime.py @@ -0,0 +1,97 @@ +"""Real factory/worker/transport collaboration shared by the two runtime Cases.""" + +import asyncio +import json +import threading +from contextlib import nullcontext +from unittest.mock import Mock + +from jiuwen_bridge_fixture import profile_for, provider +from nexent.core.agents import nexent_agent +from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.core_agent import CoreAgent +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import LanePolicy, ThreadManager +from nexent.core.utils.observer import MessageObserver + + +async def run_worker(monkeypatch, protocol, scenario="normal"): + def forbidden(*args, **kwargs): + raise AssertionError("Native HTTP client or CodeAgent was constructed") + + monkeypatch.setattr("openjiuwen.core.foundation.llm.model.create_model_client", forbidden) + monkeypatch.setattr(CoreAgent, "__init__", forbidden) + # Keep real model transport and observer events, but isolate the external monitoring sink. + monitoring = Mock() + monitoring.trace_llm_request.side_effect = lambda *args, **kwargs: nullcontext(None) + monkeypatch.setattr("nexent.core.model_service.service.get_monitoring_manager", lambda: monitoring) + owned_cards = [] + prepare = JiuwenAdapter.prepare + + def track_prepare(adapter): + prepare(adapter) + owned_cards.extend(card.id for card in adapter.native_agent.ability_manager.list()) + + monkeypatch.setattr(JiuwenAdapter, "prepare", track_prepare) + async with provider(scenario, protocol) as (url, state): + class BridgeAddTool: + name, description = "add", "Add integers" + inputs = {"a": {"type": "integer"}, "b": {"type": "integer"}} + + def forward(self, a, b): + state["tool_calls"].append((a, b)) + return a + b + + monkeypatch.setattr(nexent_agent, "BridgeAddTool", BridgeAddTool, raising=False) + profile = profile_for(url, protocol) + manager = ThreadManager("jiuwen-bridge-tests", { + name: LanePolicy(name=name, max_workers=2, max_queue_size=8) + for name in ("agent-run", "model-tool-io", "mcp-session", "background-service", "sandbox", "control-io") + }) + manager.start() + info = AgentRunInfo( + query="Compute 2 plus 3", observer=MessageObserver(lang="en"), stop_event=threading.Event(), + thread_manager=manager, tenant_id="spike-tenant", + model_config_list=[ModelConfig(cite_name="fixture", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, api_key="fixture-only", + resolved_profile=profile)], + agent_config=AgentConfig(agent_core="jiuwen", name="bridge-runtime", description="Fixture", + model_name="fixture", max_steps=3, instructions="Answer the current request.", + tools=[ToolConfig(name="add", class_name="BridgeAddTool", + source="local", params={})]), + ) + async def cancel_after_first_response(): + while not state["requests"]: + await asyncio.sleep(0.01) + await asyncio.sleep(0.1) + info.stop_event.set() + + canceller = asyncio.create_task(cancel_after_first_response()) if scenario == "cancel" else None + try: + output = [json.loads(event) async for event in agent_run(info, thread_manager=manager)] + finally: + if canceller is not None: + await canceller + drained = await manager.shutdown(timeout=5) + assert not drained.stuck_execution_ids + from openjiuwen.core.runner import Runner + + assert all(Runner.resource_mgr.get_tool(card_id) is None for card_id in owned_cards) + assert manager.snapshot().active_count == 0 + if scenario == "cancel": + for _ in range(50): + if state.get("cancel_response_closed"): + break + await asyncio.sleep(0.01) + assert not state["server_errors"] + return info, state, output + + +def stop_infrastructure(): + import nexent.monitor.monitoring as monitoring_module + from nexent.core.concurrency.helpers import shutdown_fallback_thread_manager + + if monitoring_module._monitoring_buffer is not None: + monitoring_module._monitoring_buffer.stop() + asyncio.run(shutdown_fallback_thread_manager(timeout=5)) diff --git a/test-e2e/infra/automation/jiuwen_collaboration_runtime.py b/test-e2e/infra/automation/jiuwen_collaboration_runtime.py new file mode 100644 index 0000000000..1b10b8bf54 --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_collaboration_runtime.py @@ -0,0 +1,216 @@ +"""Real model/A2A HTTP fixtures for configured mixed-Core invocations.""" + +import asyncio +import json +from contextlib import asynccontextmanager, nullcontext +from threading import Event +from unittest.mock import Mock + +from jiuwen_bridge_fixture import profile_for +from nexent.core.agents.agent_model import ( + AgentConfig, + AgentRunInfo, + ExternalA2AAgentConfig, + ModelConfig, + ToolConfig, +) +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.core_adapters.smolagents import SmolagentsAdapter +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import LanePolicy, ThreadManager +from nexent.core.utils.observer import MessageObserver + + +@asynccontextmanager +async def collaboration_provider(parent_core, child_core, protocol, remote_protocol, fault): + state = {"parent": [], "child": [], "remote": [], "closed": False, "errors": [], "handlers": set(), "auth_ok": [], "effects": [], "parent_effects": []} + + async def handle(reader, writer): + state["handlers"].add(asyncio.current_task()) + try: + headers = await reader.readuntil(b"\r\n\r\n") + path = headers.split(b" ")[1].decode() + size = int(next(line.split(b":", 1)[1] for line in headers.split(b"\r\n") + if line.lower().startswith(b"content-length:"))) + body = json.loads(await reader.readexactly(size)) + key = "remote" if path.startswith("/remote") else "child" if path.startswith("/child") else "parent" + state[key].append(body) + if key in {"parent", "child"}: + expected = b"authorization: bearer " + (b"parent-only" if key == "parent" else b"child-only") + state["auth_ok"].append(expected in headers.lower()) + if fault in {"cancel", "timeout"} and key == ("remote" if child_core == "a2a" else "child"): + await reader.read() + state["closed"] = True + return + if key == "remote": + payload = {"message": {"role": "agent", "parts": [{"text": "CHILD_DONE"}]}} + if remote_protocol == "JSONRPC": + payload = {"jsonrpc": "2.0", "id": 1, "result": payload} + encoded = json.dumps(payload).encode() + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(encoded)).encode() + b"\r\nConnection: close\r\n\r\n" + encoded) + await writer.drain() + return + core = parent_core if key == "parent" else child_core + first = len(state[key]) == 1 + call = first and key == "parent" + if call and core == "jiuwen" and protocol == "native": + content = "" + calls = [{"id": "call-child", "type": "function", "function": { + "name": "delegate", "arguments": json.dumps({"task": "CHILD_TASK_ONLY"})}}] + elif call: + content = '\nvalue = delegate(task="CHILD_TASK_ONLY")\nprint(value)\n' + calls = [] + elif key == "child" and first: + if core == "jiuwen" and protocol == "native": + content = "" + calls = [{"id": "child-effect", "type": "function", "function": { + "name": "child_echo", "arguments": "{}"}}] + else: + content = '\nprint(child_echo())\n' + calls = [] + else: + content = "PARENT_DONE" if key == "parent" else "CHILD_DONE" + if core == "smolagents": + content = '' + content + '' + calls = [] + finish = "tool_calls" if calls else "stop" + message = {"role": "assistant", "content": content} + if calls: + message["tool_calls"] = calls + value = {"id": "fixture-response", "model": body["model"], "object": "chat.completion", + "choices": [{"index": 0, "message": message, "finish_reason": finish}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}} + if body.get("stream"): + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + delta = dict(message) + if calls: + delta["tool_calls"] = [{"index": i, **item} for i, item in enumerate(calls)] + chunk = {"id": value["id"], "model": value["model"], "object": "chat.completion.chunk", + "choices": [{"index": 0, "delta": delta, "finish_reason": None}]} + writer.write(("data: " + json.dumps(chunk) + "\n\n").encode()) + chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": finish}] + chunk["usage"] = value["usage"] + writer.write(("data: " + json.dumps(chunk) + "\n\ndata: [DONE]\n\n").encode()) + else: + encoded = json.dumps(value).encode() + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(encoded)).encode() + b"\r\nConnection: close\r\n\r\n" + encoded) + await writer.drain() + except (ConnectionError, asyncio.IncompleteReadError): + pass + except Exception as exc: # noqa: BLE001 - Record unexpected fixture errors for terminal assertions. + state["errors"].append(type(exc).__name__) + finally: + writer.close() + await writer.wait_closed() + state["handlers"].discard(asyncio.current_task()) + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + try: + async with server: + yield f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}", state + finally: + for task in tuple(state["handlers"]): + task.cancel() + await asyncio.gather(*state["handlers"], return_exceptions=True) + + +async def run_collaboration(monkeypatch, parent_core="jiuwen", child_core="jiuwen", protocol="native", + remote_protocol="JSONRPC", fault=None): + monitoring = Mock() + monitoring.trace_llm_request.side_effect = lambda *args, **kwargs: nullcontext(None) + monkeypatch.setattr("nexent.core.model_service.service.get_monitoring_manager", lambda: monitoring) + def forbid(*args, **kwargs): + raise AssertionError("Native Jiuwen provider client must not be used") + monkeypatch.setattr("openjiuwen.core.foundation.llm.model.create_model_client", forbid) + cores, cards = [], [] + for adapter_type in (JiuwenAdapter, SmolagentsAdapter): + original = adapter_type.run + def run(adapter, _original=original): + cores.append((adapter.run_info.agent_config.name, adapter.core_name)) + return _original(adapter) + monkeypatch.setattr(adapter_type, "run", run) + original_prepare = JiuwenAdapter.prepare + def prepare(adapter): + original_prepare(adapter) + cards.extend(card.id for card in adapter.native_agent.ability_manager.list()) + monkeypatch.setattr(JiuwenAdapter, "prepare", prepare) + async with collaboration_provider(parent_core, child_core, protocol, remote_protocol, fault) as (url, state): + from nexent.core.agents import nexent_agent + from smolagents import Tool + + class ChildEchoTool(Tool): + name, description, inputs, output_type = "child_echo", "Child effect", {}, "string" + + def forward(self): + state["effects"].append("child") + return "CHILD_EFFECT" + + class ParentOnlyTool(Tool): + name, description, inputs, output_type = "parent_only", "Parent effect", {}, "string" + + def forward(self): + state["parent_effects"].append("parent") + raise AssertionError("Child cannot execute parent resources") + + monkeypatch.setattr(nexent_agent, "ChildEchoTool", ChildEchoTool, raising=False) + monkeypatch.setattr(nexent_agent, "ParentOnlyTool", ParentOnlyTool, raising=False) + + def model(path, temperature, credential, outlet): + profile = profile_for(url + path, protocol) + data = profile.model_dump() + data["outlet_id"] = outlet + data["connection"]["credential_ref"]["model_id"] = outlet + data["parameter_rules"].append({ + "key": "temperature", "schema": {"type": "number", "minimum": 0, "maximum": 2}, + "owner": "combination", "allowed_scopes": ["outlet", "conversation"], "required": False, + "editable": True, "visible": True, "ui_group": "sampling", + "source": {"ref": "controlled_temperature", "version": 1, "evidence": "Controlled fixture"}, + "api_binding": {"path": "/temperature", "transform_ref": "identity_v1", "transform_version": 1}, + }) + data["parameter_values"]["temperature"] = temperature + profile = type(profile).model_validate(data) + return ModelConfig(cite_name="shared", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, api_key=credential, temperature=temperature, resolved_profile=profile) + child_model = model("/child", 0.2, "child-only", 8) + child = AgentConfig(agent_core="jiuwen" if child_core == "a2a" else child_core, + name="delegate", description="Delegate task", invocation_name="delegate", model_name="shared", tools=[ToolConfig(name="child_echo", class_name="ChildEchoTool", + source="local", params={})], + max_steps=3, runtime_model_config=child_model, instructions="CHILD_SYSTEM_ONLY") + root = AgentConfig(agent_core=parent_core, name="parent", description="Parent", model_name="shared", + tools=[ToolConfig(name="parent_only", class_name="ParentOnlyTool", source="local", params={})], + instructions="PARENT_SYSTEM_ONLY", max_steps=3, + worker_agents=[] if child_core == "a2a" else [child]) + if child_core == "a2a": + root.external_a2a_agents = [ExternalA2AAgentConfig(agent_id="remote-id", name="delegate", + url=url + "/remote", protocol_type=remote_protocol, timeout=0.3 if fault == "timeout" else 5)] + manager = ThreadManager("collaboration-runtime", { + name: LanePolicy(name=name, max_workers=2 if name == "sandbox" and fault != "capacity" else 1, max_queue_size=8) + for name in ("agent-run", "model-tool-io", "mcp-session", "background-service", "sandbox", "control-io")}) + manager.start() + info = AgentRunInfo(query="PARENT_TASK_ONLY", agent_config=root, + model_config_list=[model("/parent", 0.9, "parent-only", 7)], observer=MessageObserver(lang="en"), + stop_event=Event(), thread_manager=manager, collaboration_timeout_seconds=0.4 if fault == "capacity" else 5, + tenant_id="spike-tenant", user_context={"user_id": "trusted"}) + async def cancel(): + while not (state["child"] or state["remote"]): + await asyncio.sleep(0.01) + info.stop_event.set() + canceller = asyncio.create_task(cancel()) if fault == "cancel" else None + try: + output = [json.loads(event) async for event in agent_run(info, thread_manager=manager)] + finally: + if canceller: + await canceller + drained = await manager.shutdown(timeout=5) + assert not drained.stuck_execution_ids + for _ in range(50): + if not fault or state["closed"]: + break + await asyncio.sleep(0.01) + from openjiuwen.core.runner import Runner + assert all(Runner.resource_mgr.get_tool(card) is None for card in cards) + assert manager.snapshot().active_count == 0 + assert not state["errors"] + return info, state, output, cores diff --git a/test-e2e/infra/automation/jiuwen_context_runtime.py b/test-e2e/infra/automation/jiuwen_context_runtime.py new file mode 100644 index 0000000000..deff04d3fc --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_context_runtime.py @@ -0,0 +1,354 @@ +"""Isolated PostgreSQL and real Core/model transport proof for saved CM and history.""" + +import asyncio +import json +import re +import subprocess +import sys +from pathlib import Path + +import httpx +import time +import uuid +from contextlib import contextmanager, nullcontext +from threading import Event +from unittest.mock import Mock + +from jiuwen_bridge_fixture import profile_for +from nexent.core.agents.agent_model import ( + AgentConfig, + AgentRunInfo, + ModelConfig, + ToolConfig, +) +from nexent.core.agents.context import ContextManagerConfig +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import LanePolicy, ThreadManager +from nexent.core.utils.observer import MessageObserver +from sqlalchemy import create_engine, insert, select, text +from sqlalchemy.orm import sessionmaker + +if sys.platform == "win32": + for folder in ("pywin32_system32", "win32", "win32/lib"): + sys.path.append(str(Path(httpx.__file__).resolve().parents[1] / folder)) + + +def docker(*args): + result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=40, check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + if result.returncode: + raise AssertionError("Isolated context PostgreSQL command failed") + return result.stdout.strip() + + +@contextmanager +def context_database(monkeypatch): + name = "codex-jiuwen-context-" + uuid.uuid4().hex[:10] + docker("run", "-d", "--name", name, "-p", "127.0.0.1::5432", + "-e", "POSTGRES_HOST_AUTH_METHOD=trust", "postgres:15-alpine") + engine = None + try: + port = int(docker("port", name, "5432/tcp").rsplit(":", 1)[1]) + for _ in range(150): + ready = subprocess.run(["docker", "exec", name, "pg_isready", "-h", "127.0.0.1", "-U", "postgres"], + capture_output=True, timeout=5, check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + if ready.returncode == 0: + break + time.sleep(0.1) + else: + raise AssertionError("Isolated history PostgreSQL did not start") + engine = create_engine(f"postgresql://postgres@127.0.0.1:{port}/postgres") + from database import client, conversation_db + from database.db_models import ( + AgentInfo, + ConversationMessage, + ConversationMessageUnit, + ConversationRecord, + ConversationSourceSearch, + ConversationSourceImage, + ) + + monkeypatch.setattr(client.db_client, "session_maker", sessionmaker(bind=engine)) + # Authentication lookup is the controlled external boundary, not the history DAL. + monkeypatch.setattr(conversation_db, "_get_user_tenant", lambda _: {"tenant_id": "spike-tenant"}) + with engine.begin() as connection: + connection.execute(text("CREATE SCHEMA nexent")) + for model in (AgentInfo, ConversationRecord, ConversationMessage, ConversationMessageUnit, + ConversationSourceSearch, ConversationSourceImage): + model.__table__.create(engine) + yield engine + finally: + if engine is not None: + engine.dispose() + docker("rm", "-f", name) + + +def seed_agent(engine, core): + from database.db_models import AgentInfo + with engine.begin() as connection: + for version in (0, 1): + connection.execute(insert(AgentInfo).values(agent_id=42, version_no=version, agent_core=core, + name="history_agent", description="History fixture", tenant_id="spike-tenant", + current_version_no=1, enable_context_manager=False, created_by="owner", delete_flag="N")) + + +def save_configuration(engine, core, cm): + from database.agent_db import ( + search_agent_info_by_agent_id, + update_agent_draft_fields, + ) + from database.db_models import AgentInfo + assert update_agent_draft_fields(42, "spike-tenant", {"agent_core": core, "enable_context_manager": cm}) == 1 + assert search_agent_info_by_agent_id(42, "spike-tenant", 1)["agent_core"] != core + # Publishing is an existing external boundary: prepare its immutable new snapshot, + # rather than mutating the old version or testing an unrelated publication workflow. + with engine.begin() as connection: + connection.execute(insert(AgentInfo).values(agent_id=42, version_no=2, agent_core=core, + name="history_agent", description="History fixture", tenant_id="spike-tenant", + enable_context_manager=cm, created_by="owner", delete_flag="N")) + assert update_agent_draft_fields(42, "spike-tenant", {"current_version_no": 2}) == 1 + + +def message(conversation, index, role, content, files=None, status="completed"): + from database.conversation_db import create_conversation_message + return create_conversation_message({"conversation_id": conversation, "message_idx": index, + "role": role, "content": content, "minio_files": files}, "owner", status=status) + + +def record_output(conversation, index, output): + from database.conversation_db import create_message_units + from utils.agent_stream_utils import serialize_stream_unit_content + answer = "".join(event.get("content", "") for event in output if event.get("type") == "final_answer") + mid = message(conversation, index, "assistant", answer) + units = [] + for event in output: + if event.get("type") == "history_summary": + continue + content = event.get("content", "") + if not isinstance(content, str): + content = json.dumps(content) + units.append({"type": event["type"], "content": serialize_stream_unit_content(event, content), + "unit_index": len(units), "unit_type": event["type"], + "unit_content": serialize_stream_unit_content(event, content), "unit_status": "completed", + "tool_call_id": event.get("tool_call_id"), "invocation_id": event.get("invocation_id")}) + from database.conversation_db import update_message_unit_status + for unit_id in create_message_units(units, mid, conversation, "owner"): + update_message_unit_status(unit_id, "completed", user_id="owner") + return mid + + +async def run_context_scenario(monkeypatch, engine, original="smolagents", protocol="native", cm=False, + fault=None, existing_checkpoint=False): + from database.agent_db import search_agent_info_by_agent_id + from database.agent_version_db import query_current_version_no + from database.conversation_db import ( + create_conversation, + get_conversation_history, + get_historical_context, + ) + from database.db_models import ConversationMessageUnit + from nexent.core.agents import nexent_agent + from services.conversation_management_service import ( + persist_history_summary_candidate, + ) + from smolagents import Tool + + changed = "jiuwen" if original == "smolagents" else "smolagents" + seed_agent(engine, original) + conversation = create_conversation("Existing Core conversation", "owner", agent_id=42)["conversation_id"] + effects, calls, summary_requests, read_errors = [], [], [], [] + phase = {"value": "seed", "stage_calls": 0, "summary_started": False, "closed": False} + summary_schema = ContextManagerConfig().summary_json_schema + markdown = "# Compact Result of History\n" + "\n".join( + "## " + key.replace("_", " ").title() + "\nOLD_FACT_ALPHA s3://files/old.txt TOOL_RESULT_ALPHA" + for key in summary_schema) + + async def handle(reader, writer): + try: + headers = await reader.readuntil(b"\r\n\r\n") + length = int(next(line.split(b":", 1)[1] for line in headers.split(b"\r\n") + if line.lower().startswith(b"content-length:"))) + body = json.loads(await reader.readexactly(length)) + is_summary = any("exact seven" in str(m.get("content")) or "Produce a structured summary" in + str(m.get("content")) or "Update the summary keeping" in str(m.get("content")) + for m in body["messages"]) + if is_summary: + summary_requests.append(body) + phase["summary_started"] = True + if fault == "cancel_summary": + await reader.read() + phase["closed"] = True + return + source = str(body["messages"]) + markers = sorted(set(re.findall(r"\b(?=[A-Za-z0-9-]*\d)[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+){2,}\b", source))) + content, tool_calls = ("Invalid summary" if fault == "invalid_summary" else + markdown + "\nPreserved identifiers: " + " ".join(markers)), [] + else: + calls.append((phase["value"], body)) + phase["stage_calls"] += 1 + core = original if phase["value"] == "seed" else changed + first = phase["value"] == "seed" and phase["stage_calls"] == 1 + if first and core == "jiuwen" and protocol == "native": + content = "" + tool_calls = [{"id": "saved-provider-call", "type": "function", "function": { + "name": "remember", "arguments": "{}"}}] + elif first: + content, tool_calls = "\nprint(remember())\n", [] + else: + content, tool_calls = ("OLD_FACT_ALPHA" if phase["value"] == "seed" else "FOLLOWUP_ALPHA"), [] + if core == "smolagents": + content = "" + content + "" + value = {"id": "context-response", "model": body["model"], "object": "chat.completion", + "choices": [{"index": 0, "message": {"role": "assistant", "content": content, + **({"tool_calls": tool_calls} if tool_calls else {})}, + "finish_reason": "tool_calls" if tool_calls else "stop"}], + "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}} + if body.get("stream"): + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + delta = value["choices"][0]["message"] + if tool_calls: + delta["tool_calls"] = [{"index": i, **call} for i, call in enumerate(tool_calls)] + chunk = {**value, "object": "chat.completion.chunk", "choices": [ + {"index": 0, "delta": delta, "finish_reason": None}]} + chunk.pop("usage", None) + writer.write(("data: " + json.dumps(chunk) + "\n\n").encode()) + chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": value["choices"][0]["finish_reason"]}] + chunk["usage"] = value["usage"] + writer.write(("data: " + json.dumps(chunk) + "\n\ndata: [DONE]\n\n").encode()) + else: + encoded = json.dumps(value).encode() + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(encoded)).encode() + b"\r\nConnection: close\r\n\r\n" + encoded) + await writer.drain() + except (ConnectionError, asyncio.IncompleteReadError): + pass + except Exception as error: # noqa: BLE001 - Collect fixture failures for terminal assertions. + read_errors.append(type(error).__name__) + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}" + class RememberTool(Tool): + name, description, inputs, output_type = "remember", "Remember a historical fact", {}, "string" + def forward(self): + effects.append("once") + return "TOOL_RESULT_ALPHA" + monkeypatch.setattr(nexent_agent, "RememberTool", RememberTool, raising=False) + monitoring = Mock() + monitoring.trace_llm_request.side_effect = lambda *a, **k: nullcontext(None) + monkeypatch.setattr("nexent.core.model_service.service.get_monitoring_manager", lambda: monitoring) + def forbidden(*a, **k): + raise AssertionError("Jiuwen native provider client must not send") + monkeypatch.setattr("openjiuwen.core.foundation.llm.model.create_model_client", forbidden) + profile = profile_for(url, protocol) + manager = ThreadManager("context-runtime", {name: LanePolicy(name=name, max_workers=2, max_queue_size=8) + for name in ("agent-run", "model-tool-io", "mcp-session", "control-io", "sandbox", "background-service")}) + manager.start() + infos, outputs = [], [] + async def info_for(query, current_mid): + version = query_current_version_no(42, "spike-tenant") + saved = search_agent_info_by_agent_id(42, "spike-tenant", version) + from agents.create_agent_info import build_agent_context_policy + cm_config = ContextManagerConfig(effective_input_limit_tokens=200000, + compaction_trigger_threshold_tokens=35000, compaction_target_tokens=30000, + max_summary_input_tokens=120000, + max_summary_reduce_tokens=1000, policy_layers=build_agent_context_policy(saved["enable_context_manager"])) + cm_config.history_summary_sink = lambda candidate: persist_history_summary_candidate( + conversation, candidate, "owner", "spike-tenant") + info = AgentRunInfo(query=query, stop_event=Event(), observer=MessageObserver(lang="en"), + thread_manager=manager, tenant_id="spike-tenant", user_id="owner", conversation_id=conversation, + model_config_list=[ModelConfig(cite_name="fixture", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, api_key="fixture-only", resolved_profile=profile)], + agent_config=AgentConfig(name="history_agent", description="History fixture", agent_core=saved["agent_core"], + version_no=version, model_name="fixture", max_steps=3, context_manager_config=cm_config, + tools=[ToolConfig(name="remember", class_name="RememberTool", source="local", params={})])) + from consts.model import AgentRequest, HistoryItem + from management.services.agent import run as application + from unittest.mock import AsyncMock + + monkeypatch.setattr(application, "create_agent_run_info", AsyncMock(return_value=info)) + monkeypatch.setattr(application, "build_memory_context", lambda *a, **k: None) + monkeypatch.setattr(application.agent_run_manager, "register_agent_run", lambda *a, **k: None) + request = AgentRequest(agent_id=42, conversation_id=conversation, query=query, is_debug=False, + history=[HistoryItem(role="user", content="UNTRUSTED_CLIENT_HISTORY")]) + info, _ = await application.prepare_agent_run(request, "owner", "spike-tenant", language="en") + infos.append(info) + return info + try: + async with server: + first = message(conversation, 1, "user", "Perform the original task", files=[{ + "name": "old.txt", "url": "s3://files/old.txt"}]) + info = await info_for("Perform the original task", first) + output = [json.loads(chunk) async for chunk in agent_run(info, thread_manager=manager)] + assert info.attempt_outcome == "completed", output[-2:] + old_assistant = record_output(conversation, 2, output) + if existing_checkpoint or fault is not None: + persist_history_summary_candidate(conversation, {"summary": {"markdown": markdown}, + "covered_through_message_id": old_assistant}, "owner", "spike-tenant") + outputs.append(output) + for index in range(3, 19, 2): + message(conversation, index, "user", "Completed earlier request " + "old data " * 500) + message(conversation, index + 1, "assistant", "Completed earlier answer " + "old data " * 500) + save_configuration(engine, changed, cm) + before = get_conversation_history(conversation, "owner") + phase.update(value="followup", stage_calls=0) + current_mid = message(conversation, 19, "user", "Recall the completed fact; do not repeat tools") + info = await info_for("CURRENT_FOLLOWUP_ONLY", current_mid) + async def cancel_summary(): + for _ in range(1000): + if phase["summary_started"]: + phase["cancel_started"] = time.monotonic() + info.stop_event.set() + return + await asyncio.sleep(0.01) + raise AssertionError("Summary did not start") + canceller = asyncio.create_task(cancel_summary()) if fault == "cancel_summary" else None + try: + output = [json.loads(chunk) async for chunk in agent_run(info, thread_manager=manager)] + finally: + if canceller: + await canceller + outputs.append(output) + cancelled = fault == "cancel_summary" + if cancelled: + phase["cancel_elapsed"] = time.monotonic() - phase["cancel_started"] + if not cancelled: + assert info.attempt_outcome == "completed", output[-2:] + record_output(conversation, 20, output) + else: + assert info.attempt_outcome == "stopped" + message(conversation, 20, "assistant", "Stopped before final response", status="stopped") + with engine.connect() as connection: + checkpoints_before_recovery = connection.execute(select(ConversationMessageUnit.unit_content).where( + ConversationMessageUnit.unit_type == "history_summary")).scalars().all() + fault = None + phase.update(value="reload", stage_calls=0) + current_mid = message(conversation, 21, "user", "Continue after reloading") + reloaded = await info_for("RELOADED_CURRENT_ONLY", current_mid) + result = [json.loads(chunk) async for chunk in agent_run(reloaded, thread_manager=manager)] + assert reloaded.attempt_outcome == "completed", result[-2:] + outputs.append(result) + after = get_conversation_history(conversation, "owner") + for old_message in before["message_records"]: + reloaded_message = next(message for message in after["message_records"] + if message["message_id"] == old_message["message_id"]) + for field in ("message_content", "status", "minio_files", "role", "message_index"): + assert reloaded_message[field] == old_message[field] + for old_unit in old_message["units"]: + assert old_unit in reloaded_message["units"] + with engine.connect() as connection: + checkpoints = connection.execute(select(ConversationMessageUnit.unit_content).where( + ConversationMessageUnit.unit_type == "history_summary")).scalars().all() + assert get_historical_context(conversation, current_mid, "other-owner", "spike-tenant") is None + assert get_historical_context(conversation, current_mid, "owner", "other-tenant") is None + assert get_historical_context(conversation, 999999, "owner", "spike-tenant") is None + assert not read_errors + return {"infos": infos, "calls": calls, "summaries": summary_requests, "checkpoints": checkpoints, + "checkpoints_before_recovery": checkpoints_before_recovery, + "effects": effects, "before": before, "after": after, "outputs": outputs, "phase": phase} + finally: + drained = await manager.shutdown(timeout=5) + assert not drained.stuck_execution_ids and manager.snapshot().active_count == 0 diff --git a/test-e2e/infra/automation/jiuwen_output_runtime.py b/test-e2e/infra/automation/jiuwen_output_runtime.py new file mode 100644 index 0000000000..d23dd3ff9a --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_output_runtime.py @@ -0,0 +1,50 @@ +"""Feed real runtime output through the production stream and isolated PostgreSQL DAL.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from jiuwen_context_runtime import context_database + + +async def persist_output(monkeypatch, info, output): + from consts.model import AgentRequest, HistoryItem + from database.conversation_db import create_conversation, create_conversation_message, get_conversation_history + from management.services.agent import run as application + + conversation = create_conversation("Output contract", "owner", agent_id=42)["conversation_id"] + create_conversation_message({"conversation_id":conversation,"message_idx":0,"role":"user", + "content":"Current task"}, "owner") + info.conversation_id, info.user_id = conversation, "owner" + request = AgentRequest(agent_id=42, conversation_id=conversation, query="Current task", is_debug=False, + history=[HistoryItem(role="user",content="Current task")]) + + async def source(*args, **kwargs): + for event in output: + yield json.dumps(event) + + class InlinePersistence: + async def run(self, lane, spec, function, *args, **kwargs): + return function(*args, **kwargs) + + # Runtime execution has already drained its own manager. This boundary controls only + # transport delivery and bookkeeping, retaining the production stream and actual DAL. + monkeypatch.setattr(application,"agent_run",source) + monkeypatch.setattr(application,"runtime_thread_manager",InlinePersistence()) + monkeypatch.setattr(application,"_poll_runtime_cancel_signal",AsyncMock()) + monkeypatch.setattr(application,"_unregister_agent_run_after_execution",lambda *a,**k:None) + monkeypatch.setattr(application,"_cleanup_channel_later",AsyncMock()) + monkeypatch.setattr(application.streaming_channel_manager,"complete_channel",AsyncMock()) + channel = SimpleNamespace(publish=AsyncMock()) + wire = [chunk async for chunk in application._stream_agent_chunks(request,"owner","spike-tenant",info,None, + channel=channel)] + await asyncio.sleep(0) + history = get_conversation_history(conversation,"owner") + assistant = next(m for m in history["message_records"] if m["role"]=="assistant") + envelopes=[] + for unit in assistant["units"]: + try: value=json.loads(unit["unit_content"]) + except (TypeError,ValueError):continue + if isinstance(value,dict) and value.get("runtime_mode")=="native_agent":envelopes.append(value) + return assistant,envelopes,wire diff --git a/test-e2e/infra/automation/jiuwen_resource_provider.py b/test-e2e/infra/automation/jiuwen_resource_provider.py new file mode 100644 index 0000000000..b1b862cc2e --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_resource_provider.py @@ -0,0 +1,125 @@ +"""Local deterministic OpenAI-compatible model and native MCP fixture.""" + +import asyncio +import json +import sys +import time +from contextlib import asynccontextmanager + +import uvicorn + +# Windows virtual environments may not execute pywin32's post-install path hook. +if sys.platform == "win32": + from pathlib import Path + for folder in ("pywin32_system32", "win32", "win32/lib"): + sys.path.append(str(Path(uvicorn.__file__).resolve().parents[1] / folder)) + +from fastmcp import FastMCP +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, StreamingResponse +from starlette.routing import Mount, Route + +state = {"requests": [], "script_calls": 0, "model_calls": 0, "mcp_calls": 0, "saw_skill": False, "tools": [], "messages": []} +mcp = FastMCP("jiuwen-local-fixture") + + +@mcp.tool +async def multiply(a: int, b: int, user_id: str | None = None) -> int: + state["mcp_calls"] += 1 + state["user_id"] = user_id + if a == 999: + await asyncio.Event().wait() + return a * b + + +@mcp.tool +def forbidden_tool() -> str: + raise AssertionError("An unauthorized MCP tool must never execute") + + +async def completions(request: Request): + body = await request.json() + state["model_calls"] += 1 + state["requests"].append(body) + messages = body["messages"] + state["messages"] = messages + if "FAIL_MODEL_400" in json.dumps(messages) or "FAIL_MODEL_503" in json.dumps(messages): + status_code = 400 if "FAIL_MODEL_400" in json.dumps(messages) else 503 + return JSONResponse({"error": {"message": "Fixture provider failure", "type": "server_error"}}, + status_code=status_code) + if "block_model" in json.dumps(messages): + await asyncio.Event().wait() + state["tools"] = [tool["function"]["name"] for tool in body.get("tools", [])] + state["saw_skill"] |= "Fixture skill guide" in json.dumps(messages) + results = {message.get("name") or message.get("tool_call_id", "").removeprefix("fixture_"): + message.get("content") for message in messages if message["role"] == "tool"} + if "TRANSIENT_RETRY_MARKER" in json.dumps(messages) and "add" in results and not state.get("retry_injected"): + state["retry_injected"] = True + return JSONResponse({"error": {"message": "Temporary fixture failure", "type": "server_error"}}, status_code=503) + chain = [ + ("add", {"a": 2, "b": 3}), + ("multiply", {"a": 5, "b": 99}), + ("read_skill_md", {"skill_name": "fixture_skill"}), + ("run_skill_script", {"skill_name": "fixture_skill", "script_path": "scripts/hello.py"}), + ] + text_mode = not body.get("tools") + if "BLOCK_MCP_MARKER" in json.dumps(messages): + chain = [("multiply", {"a": 999, "b": 4})] + if "PARALLEL_MARKER" in json.dumps(messages): + chain = [("parallel_executor", {"tasks": [{"tool_name": "add", "arguments": {"a": 2, "b": 3}}, + {"tool_name": "multiply", "arguments": {"a": 5, "b": 99}}]})] + if text_mode: + completed = sum("historical_tool_result" in str(message.get("content", "")) for message in messages) + remaining = chain[completed:] + else: + remaining = [(name, args) for name, args in chain if name not in results] + if "CURRENT_TASK_ONLY_MARKER" in json.dumps(messages[-1]): + assert "Earlier conversation messages, attachments and results are historical reference" in json.dumps(messages) + delta, reason = {"role": "assistant", "content": "CURRENT_RESPONSE_MARKER"}, "stop" + elif remaining: + name, args = remaining[0] + delta = {"role": "assistant", "content": "", "tool_calls": [{"index": 0, "id": f"fixture_{name}", + "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}]} + reason = "tool_calls" + if text_mode: + args_text = ", ".join(f"{key}={value!r}" for key, value in args.items()) + delta = {"role": "assistant", "content": f"\nprint({name}({args_text}))\n"} + reason = "stop" + else: + serialized = json.dumps(messages) + if "PARALLEL_MARKER" not in serialized and "BLOCK_MCP_MARKER" not in serialized: + assert "SKILL_GUIDE_MARKER" in serialized and "SCRIPT_RESULT_MARKER" in serialized + assert "20" in serialized and "5" in serialized + delta, reason = {"role": "assistant", "content": "native_result_20"}, "stop" + chunk = {"id": "fixture-completion", "object": "chat.completion.chunk", "created": int(time.time()), + "model": body["model"], "choices": [{"index": 0, "delta": delta, "finish_reason": reason}], + "usage": {"prompt_tokens": 100, "completion_tokens": 10, "total_tokens": 110}} + if body.get("stream"): + return StreamingResponse(iter([f"data: {json.dumps(chunk)}\n\n", "data: [DONE]\n\n"]), + media_type="text/event-stream") + chunk["choices"][0]["message"] = chunk["choices"][0].pop("delta") + for tool in chunk["choices"][0]["message"].get("tool_calls", []): + tool.pop("index", None) + chunk["object"] = "chat.completion" + return JSONResponse(chunk) + + +async def status(request): + return JSONResponse(state) + + +mcp_app = mcp.http_app(path="/mcp") + + +@asynccontextmanager +async def lifespan(app): + async with mcp_app.lifespan(app): + yield + + +app = Starlette(routes=[Route("/v1/chat/completions", completions, methods=["POST"]), + Route("/state", status), Mount("/", app=mcp_app)], lifespan=lifespan) + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[1]), log_level="warning", timeout_graceful_shutdown=1) diff --git a/test-e2e/infra/automation/jiuwen_resource_runtime.py b/test-e2e/infra/automation/jiuwen_resource_runtime.py new file mode 100644 index 0000000000..afc9c74a12 --- /dev/null +++ b/test-e2e/infra/automation/jiuwen_resource_runtime.py @@ -0,0 +1,191 @@ +"""Real native/text resource flows through managed workers and local protocol servers.""" + +import asyncio +import json +import socket +import subprocess +import sys +import threading +import time +from contextlib import contextmanager, nullcontext +from pathlib import Path +from unittest.mock import Mock + +import httpx + +if sys.platform == "win32": + for folder in ("pywin32_system32", "win32", "win32/lib"): + sys.path.append(str(Path(httpx.__file__).resolve().parents[1] / folder)) + +from jiuwen_bridge_fixture import profile_for +from nexent.core.agents import nexent_agent +from nexent.core.agents.agent_model import AgentConfig, AgentHistory, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.core_agent import CoreAgent +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import LanePolicy, ThreadManager +from nexent.core.utils.observer import MessageObserver + + +@contextmanager +def resource_provider(tmp_path): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + with (tmp_path / "resource-provider.log").open("w", encoding="utf-8") as log: + process = subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("jiuwen_resource_provider.py")), str(port)], + stdout=log, stderr=log, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + url = f"http://127.0.0.1:{port}" + try: + for _ in range(150): + if process.poll() is not None: + raise AssertionError("Controlled resource server exited") + try: + if httpx.get(url + "/state", timeout=.2, trust_env=False).status_code == 200: + break + except httpx.HTTPError: + time.sleep(.05) + else: + raise AssertionError("Controlled resource server did not become ready") + yield url + finally: + process.terminate() + process.wait(timeout=5) + + +async def run_resources(monkeypatch, tmp_path, url, protocol, scenario="normal", *, docker_sandbox=False): + def forbidden(*args, **kwargs): + raise AssertionError("Native provider client or CodeAgent constructed") + + monkeypatch.setattr("openjiuwen.core.foundation.llm.model.create_model_client", forbidden) + monkeypatch.setattr(CoreAgent, "__init__", forbidden) + monitoring = Mock() + monitoring.trace_llm_request.side_effect = lambda *args, **kwargs: nullcontext(None) + monkeypatch.setattr("nexent.core.model_service.service.get_monitoring_manager", lambda: monitoring) + calls, owned_cards = [], [] + + class ResourceAddTool: + name, description = "add", "Add integers" + inputs = {"a": {"type": "integer"}, "b": {"type": "integer"}} + + def forward(self, a, b): + calls.append((a, b)) + return a + b + + monkeypatch.setattr(nexent_agent, "ResourceAddTool", ResourceAddTool, raising=False) + prepare = JiuwenAdapter.prepare + + def track_prepare(adapter): + prepare(adapter) + if docker_sandbox: + assert adapter.resources.agent.python_executor._nexent_backend == "docker" + owned_cards.extend(card.id for card in adapter.native_agent.ability_manager.list()) + + monkeypatch.setattr(JiuwenAdapter, "prepare", track_prepare) + close = JiuwenAdapter.close + + def track_close(adapter): + if adapter.native_agent is not None: + owned_cards.extend(card.id for card in adapter.native_agent.ability_manager.list()) + close(adapter) + + monkeypatch.setattr(JiuwenAdapter, "close", track_close) + skill = tmp_path / "skills" / "fixture_skill" + (skill / "scripts").mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text( + "---\nname: fixture_skill\ndescription: Fixture skill guide\n---\nSKILL_GUIDE_MARKER\n", encoding="utf-8", + ) + effect = tmp_path / "run/outputs/script-effects.txt" if docker_sandbox else tmp_path / "script-effects.txt" + (skill / "scripts/hello.py").write_text( + f"from pathlib import Path\nimport os\np=Path(os.environ.get('NEXENT_OUTPUT_DIR', {str(effect.parent)!r}))/" + "'script-effects.txt'\n" + "p.write_text(p.read_text()+'x' if p.exists() else 'x')\nprint('SCRIPT_RESULT_MARKER')\n", encoding="utf-8", + ) + hidden = skill.parent / "forbidden_skill" + hidden.mkdir(exist_ok=True) + (hidden / "SKILL.md").write_text("---\nname: forbidden_skill\ndescription: SECRET_SKILL\n---\n", encoding="utf-8") + parameters = {"local_skills_dir": str(skill.parent), "authorized_skill_names": ["fixture_skill"], + "isolated_skills_root": True} + sandbox = None + if docker_sandbox: + from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel + + parameters["workspace_path"] = str(tmp_path / "run") + sandbox = SandboxConfig(level=SandboxLevel.DOCKER, docker_image="nexent/nexent-sandbox-full:latest", + network_disabled=False, timeout_seconds=30, workspace_mode="bind", container_workspace_root="/work", + failure_policy="error", extra_kwargs={"workspace_root": str(tmp_path)}) + tools = [ToolConfig(name="add", class_name="ResourceAddTool", source="local", params={}), + ToolConfig(name="multiply", class_name="multiply", source="mcp", params={"b": 4}), + ToolConfig(name="read_skill_md", class_name="ReadSkillMdTool", source="builtin", params=parameters), + ToolConfig(name="run_skill_script", class_name="RunSkillScriptTool", source="builtin", params=parameters)] + if scenario == "parallel": + tools.append(ToolConfig(name="parallel_executor", class_name="ParallelExecutorTool", source="builtin", params={})) + manager = ThreadManager("jiuwen-resource-tests", { + name: LanePolicy(name=name, max_workers=1 if name == "model-tool-io" else 2, max_queue_size=8) + for name in ("agent-run", "model-tool-io", "mcp-session", "sandbox", "control-io", "background-service") + }) + manager.start() + profile = profile_for(url, protocol) + info = AgentRunInfo( + query="Run the authorized resource chain" + (" BLOCK_MCP_MARKER" if scenario in {"timeout", "cancel"} + else " PARALLEL_MARKER" if scenario == "parallel" else ""), + observer=MessageObserver(lang="en"), stop_event=threading.Event(), thread_manager=manager, + tenant_id="spike-tenant", user_context={"user_id": "captured-user"}, + sandbox_config=sandbox, workspace_path=str(tmp_path / "run") if docker_sandbox else None, + mcp_host=[url + "/mcp"], mcp_request_timeout_seconds=5, mcp_close_timeout_seconds=2, + mcp_tool_timeout_seconds=.4 if scenario == "timeout" else 10, + model_config_list=[ModelConfig(cite_name="fixture", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, api_key="fixture-only", resolved_profile=profile)], + agent_config=AgentConfig(agent_core="jiuwen", name="resource-runtime", description="Fixture", + model_name="fixture", max_steps=6, tools=tools), + ) + async def consume(run_info): + return [json.loads(event) async for event in agent_run(run_info, thread_manager=manager)] + + async def cancel_when_mcp_started(): + async with httpx.AsyncClient(trust_env=False) as client: + for _ in range(500): + state = (await client.get(url + "/state")).json() + if state["mcp_calls"]: + info.stop_event.set() + return + await asyncio.sleep(.01) + raise AssertionError("MCP did not start") + + canceller = asyncio.create_task(cancel_when_mcp_started()) if scenario == "cancel" else None + started = time.monotonic() + try: + output = await consume(info) + if canceller: + await canceller + elapsed = time.monotonic() - started + async with httpx.AsyncClient(trust_env=False) as client: + state = (await client.get(url + "/state")).json() + if scenario in {"normal", "parallel"}: + assert info.attempt_outcome == "completed", output[-1:] + previous = (len(calls), state["mcp_calls"], effect.read_text() if effect.exists() else "") + follow_up = info.model_copy(update={ + "query": "What can you do? CURRENT_TASK_ONLY_MARKER", "observer": MessageObserver(lang="en"), + "stop_event": threading.Event(), "cancellation_scope": None, + "history": [AgentHistory(role="user", content=info.query), + AgentHistory(role="assistant", content="native_result_20")], + }) + second = await consume(follow_up) + assert follow_up.attempt_outcome == "completed", second[-1:] + assert any("CURRENT_RESPONSE_MARKER" in str(event) for event in second) + async with httpx.AsyncClient(trust_env=False) as client: + later = (await client.get(url + "/state")).json() + assert previous == (len(calls), later["mcp_calls"], effect.read_text() if effect.exists() else "") + return info, state, output, calls, effect.read_text() if effect.exists() else "", elapsed + finally: + if canceller and not canceller.done(): + canceller.cancel() + await asyncio.gather(canceller, return_exceptions=True) + drained = await manager.shutdown(timeout=5) + assert not drained.stuck_execution_ids + assert manager.snapshot().active_count == 0 + from openjiuwen.core.runner import Runner + + assert all(Runner.resource_mgr.get_tool(card_id) is None for card_id in owned_cards) diff --git a/test/automation/d1/test_agent_core_factory.py b/test/automation/d1/test_agent_core_factory.py new file mode 100644 index 0000000000..7839f86d0f --- /dev/null +++ b/test/automation/d1/test_agent_core_factory.py @@ -0,0 +1,134 @@ +"""Formal factory, configuration compilation and observer contracts.""" + +import asyncio +import json +from threading import Event +from types import SimpleNamespace + +import pytest + +from nexent.core.agents.agent_model import AgentConfig, AgentHistory, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.context.models import ContextItemInput, ContextItemType +from nexent.core.agents.core_adapters import AgentCoreAdapter, AgentCoreFactory, UnsupportedAgentCoreConfig +from nexent.core.agents.core_adapters.events import JiuwenEventAdapter +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter, _tool_schema +from nexent.core.agents.core_adapters.smolagents import SmolagentsAdapter +from nexent.core.utils.observer import MessageObserver + + +def run_info(**agent_values): + return AgentRunInfo(query="fixture", observer=MessageObserver(lang="en"), stop_event=Event(), + agent_config=AgentConfig(name="fixture", description="Fixture", tools=[], model_name="model", + **agent_values), + model_config_list=[ModelConfig(cite_name="model", model_name="native-model", url="http://fixture")]) + + +def test_AGENT_CORE_D1_001_factory_dispatch_and_extensions(monkeypatch): + monkeypatch.setattr(AgentCoreFactory, "_adapters", {}) + calls = [] + legacy = AgentCoreFactory.create(run_info(), smolagents_executor=lambda info: calls.append(info.query)) + assert isinstance(legacy, SmolagentsAdapter) + legacy.validate() + legacy.run() + assert calls == ["fixture"] + assert isinstance(AgentCoreFactory.create(run_info(agent_core="jiuwen")), JiuwenAdapter) + with pytest.raises(UnsupportedAgentCoreConfig, match="Unknown"): + AgentCoreFactory.create(run_info(agent_core="unknown")) + + class Extension(AgentCoreAdapter): + core_name = "extension" + + @classmethod + def capabilities(cls): + return frozenset() + + def validate(self): + pass + + def run(self): + pass + + AgentCoreFactory.register("extension", Extension) + assert isinstance(AgentCoreFactory.create(run_info(agent_core="extension")), Extension) + with pytest.raises(ValueError, match="already registered"): + AgentCoreFactory.register("extension", Extension) + child = run_info(agent_core="jiuwen").agent_config + with pytest.raises(UnsupportedAgentCoreConfig, match="Mixed-core"): + SmolagentsAdapter(run_info(worker_agents=[child])).validate() + + +def test_AGENT_CORE_D1_002_native_config_and_fail_closed(monkeypatch, tmp_path): + items = [ + ContextItemInput(id="system:duty", type=ContextItemType.SYSTEM, content={"text": "Business duty"}), + ContextItemInput(id="system:knowledge_scope_policy", type=ContextItemType.SYSTEM, + content={"text": "Authorized knowledge scope"}, metadata={"authority": "platform"}), + ContextItemInput(id="system:code_protocol", type=ContextItemType.SYSTEM, content={"text": "Forbidden code protocol"}, + metadata={"authority": "platform"}), + ContextItemInput(id="knowledge:fixture", type=ContextItemType.KNOWLEDGE_BASE, content={"text": "Authorized facts"}), + ] + info = run_info(agent_core="jiuwen", max_steps=7, instructions="Additional business instruction", context_items=items) + info.history = [AgentHistory(role="user", content="Earlier question"), AgentHistory(role="assistant", content="Earlier answer")] + adapter = JiuwenAdapter(info) + adapter.validate() + prompt = adapter._compile_context() + assert "Business duty" in prompt and "Additional business instruction" in prompt + assert "Authorized knowledge scope" in prompt + assert "Forbidden code protocol" not in prompt + assert "Earlier answer" in str(adapter.prefix_messages) and "Authorized facts" in str(adapter.prefix_messages) + model = adapter._create_model() + assert model.model_config.model_name == "native-model" + assert model.model_client_config.api_base == "http://fixture" + schema = _tool_schema({"x": {"type": "integer"}, "y": {"type": "array", "nullable": True}}) + assert schema["required"] == ["x"] and schema["properties"]["y"]["items"] == {} + info.agent_config.enable_planning = True + with pytest.raises(UnsupportedAgentCoreConfig, match="enable_planning"): + JiuwenAdapter(info).validate() + info.agent_config.enable_planning = False + info.agent_config.tools = [ToolConfig(name="multiply", class_name="multiply", source="mcp", params={})] + with pytest.raises(ValueError, match="resolved MCP hosts"): + JiuwenAdapter(info).validate() + info.agent_config.tools = [] + info.agent_config.provide_run_summary = True + with pytest.raises(UnsupportedAgentCoreConfig, match="provide_run_summary"): + JiuwenAdapter(info).validate() + info.agent_config.provide_run_summary = False + info.model_config_list[0].prompt_cache = True + with pytest.raises(UnsupportedAgentCoreConfig, match="prompt_cache"): + JiuwenAdapter(info).validate() + + from nexent.core.agents.core_adapters.skills import NexentSkillRail + + root = tmp_path / "library" + allowed = root / "allowed" / "SKILL.md" + allowed.parent.mkdir(parents=True) + allowed.write_text("Authorized guide", encoding="utf-8-sig") + denied = root / "denied" / "SKILL.md" + denied.parent.mkdir() + denied.write_text("Unauthorized guide", encoding="utf-8") + rail = NexentSkillRail([str(root)], ["allowed"]) + assert asyncio.run(rail._read_skill_text(allowed)) == "Authorized guide" + with pytest.raises(PermissionError, match="authorized snapshot"): + asyncio.run(rail._read_skill_text(denied)) + escaped = tmp_path / "allowed" / "SKILL.md" + escaped.parent.mkdir() + escaped.write_text("Outside root", encoding="utf-8") + with pytest.raises(PermissionError, match="escapes"): + asyncio.run(rail._read_skill_text(escaped)) + + +def test_AGENT_CORE_D1_003_stream_and_terminal_errors(): + info = run_info(agent_core="jiuwen") + events = JiuwenEventAdapter(info.observer, "fixture") + events.emit(SimpleNamespace(type="llm_output", payload={"content": "Native delta"})) + events.emit(SimpleNamespace(type="answer", payload={"output": "Final answer", "result_type": "answer"})) + events.finish() + messages = [json.loads(message) for message in info.observer.get_cached_message()] + assert [message["type"] for message in messages].count("final_answer") == 1 + assert any("Native delta" in str(message) for message in messages) + with pytest.raises(RuntimeError, match="multiple"): + events.emit(SimpleNamespace(type="answer", payload={"output": "Second"})) + events = JiuwenEventAdapter(info.observer, "fixture") + with pytest.raises(RuntimeError, match="failed"): + events.emit(SimpleNamespace(type="answer", payload={"result_type": "error", "output": "failed"})) + with pytest.raises(RuntimeError, match="without"): + events.finish() diff --git a/test/automation/d2/test_agent_core_factory_contract.py b/test/automation/d2/test_agent_core_factory_contract.py new file mode 100644 index 0000000000..45c08d81b6 --- /dev/null +++ b/test/automation/d2/test_agent_core_factory_contract.py @@ -0,0 +1,77 @@ +"""AGENT-CORE-D2-001: Actual Agent configuration route and version contracts.""" + +import ast +import logging +import sys +from http import HTTPStatus +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from fastapi import APIRouter, FastAPI, Header, HTTPException, Request +from fastapi.testclient import TestClient +from starlette.responses import JSONResponse + +from consts.exceptions import ForbiddenError, TenantResourceLimitError +from consts.model import AgentInfoRequest, ExportAndImportAgentInfo +from database.db_models import AgentInfo + + +def test_AGENT_CORE_D2_001_config_route_and_version(monkeypatch): + # Load the actual route without initializing unrelated external services. + # The handler and Pydantic schema remain production code at the HTTP boundary. + root = Path(__file__).resolve().parents[3] + source = ast.parse((root / "backend/apps/agent_app.py").read_text(encoding="utf-8")) + handler = next(node for node in source.body if isinstance(node, ast.AsyncFunctionDef) + and node.name == "update_agent_info_api") + update = AsyncMock(return_value={"agent_id": 1, "agent_core": "jiuwen"}) + namespace = {"AgentInfoRequest": AgentInfoRequest, "Request": Request, "Header": Header, + "Optional": __import__("typing").Optional, "agent_config_router": APIRouter(prefix="/agent"), + "get_current_user_id": lambda authorization: ("fixture-user", "fixture-tenant"), + "AUDIT_DETAIL_LIST_LIMIT": 30, "update_agent_info_impl": update, + "record_security_event": lambda *args, **kwargs: None, "ForbiddenError": ForbiddenError, + "TenantResourceLimitError": TenantResourceLimitError, "HTTPException": HTTPException, + "HTTPStatus": HTTPStatus, "JSONResponse": JSONResponse, "logger": logging.getLogger(__name__)} + exec(compile(ast.Module(body=[handler], type_ignores=[]), "agent_app.py", "exec"), namespace) + app = FastAPI() + app.include_router(namespace["agent_config_router"]) + with TestClient(app) as client: + response = client.post("/agent/update", json={"agent_id": 1, "agent_core": "jiuwen"}) + assert response.status_code == 200 and response.json()["agent_core"] == "jiuwen" + request = update.call_args.args[0] + assert request.agent_core == "jiuwen" and "agent_core" in request.model_fields_set + update.reset_mock() + assert client.post("/agent/update", json={"agent_id": 1, "agent_core": "unknown"}).status_code == 422 + update.assert_not_called() + assert client.post("/agent/update", json={"agent_id": 1}).status_code == 200 + assert "agent_core" not in update.call_args.args[0].model_fields_set + + exported = ExportAndImportAgentInfo(agent_id=1, name="fixture", description="Fixture", max_steps=5, + provide_run_summary=False, enabled=True, tools=[], managed_agents=[], + agent_core="jiuwen") + assert ExportAndImportAgentInfo.model_validate(exported.model_dump()).agent_core == "jiuwen" + assert ExportAndImportAgentInfo.model_validate(exported.model_dump(exclude={"agent_core"})).agent_core == "smolagents" + column = AgentInfo.__table__.c.agent_core + assert column.type.length == 30 and not column.nullable and column.comment + assert column.default.arg == "smolagents" + + # Execute the actual publish function with isolated persistence collaborators. + source = ast.parse((root / "backend/services/agent_version_service.py").read_text(encoding="utf-8")) + publish = next(node for node in source.body if isinstance(node, ast.FunctionDef) and node.name == "publish_version_impl") + snapshot = {} + import database + + skill_db = SimpleNamespace(query_skill_instances_by_agent_id=lambda **kwargs: []) + monkeypatch.setattr(database, "skill_db", skill_db, raising=False) + monkeypatch.setitem(sys.modules, "database.skill_db", skill_db) + namespace = {"Optional": __import__("typing").Optional, "SOURCE_TYPE_NORMAL": "NORMAL", + "_ensure_system_agent_mutation_allowed": lambda *args: None, + "query_agent_draft": lambda *args: ({"agent_id": 1, "agent_core": "jiuwen", "version_no": 0}, [], []), + "get_next_version_no": lambda *args: 2, "_remove_audit_fields_for_insert": lambda data: None, + "insert_agent_snapshot": lambda data: snapshot.update(data), + "insert_version": lambda data: 1, "update_agent_current_version": lambda *args: None, + "STATUS_RELEASED": "RELEASED", + "logger": logging.getLogger(__name__)} + exec(compile(ast.Module(body=[publish], type_ignores=[]), "agent_version_service.py", "exec"), namespace) + namespace["publish_version_impl"](1, "fixture-tenant", "fixture-user") + assert snapshot["agent_core"] == "jiuwen" and snapshot["version_no"] == 2 diff --git a/test/automation/d3/jiuwen_http_fixture.py b/test/automation/d3/jiuwen_http_fixture.py new file mode 100644 index 0000000000..fa9183a776 --- /dev/null +++ b/test/automation/d3/jiuwen_http_fixture.py @@ -0,0 +1,103 @@ +"""Local deterministic OpenAI-compatible model and native MCP fixture.""" + +import asyncio +import json +import sys +import time +from contextlib import asynccontextmanager + +import uvicorn +from fastmcp import FastMCP +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, StreamingResponse +from starlette.routing import Mount, Route + +state = {"model_calls": 0, "mcp_calls": 0, "saw_skill": False, "tools": [], "messages": []} +mcp = FastMCP("jiuwen-local-fixture") + + +@mcp.tool +async def multiply(a: int, b: int, user_id: str | None = None) -> int: + state["mcp_calls"] += 1 + state["user_id"] = user_id + if a == 999: + await asyncio.Event().wait() + return a * b + + +@mcp.tool +def forbidden_tool() -> str: + raise AssertionError("An unauthorized MCP tool must never execute") + + +async def completions(request: Request): + body = await request.json() + state["model_calls"] += 1 + messages = body["messages"] + state["messages"] = messages + if "FAIL_MODEL_400" in json.dumps(messages) or "FAIL_MODEL_503" in json.dumps(messages): + status_code = 400 if "FAIL_MODEL_400" in json.dumps(messages) else 503 + return JSONResponse({"error": {"message": "Fixture provider failure", "type": "server_error"}}, + status_code=status_code) + if "block_model" in json.dumps(messages): + await asyncio.Event().wait() + state["tools"] = [tool["function"]["name"] for tool in body.get("tools", [])] + state["saw_skill"] |= "Fixture skill guide" in json.dumps(messages) + results = {message.get("name") or message.get("tool_call_id", "").removeprefix("fixture_"): + message.get("content") for message in messages if message["role"] == "tool"} + if "TRANSIENT_RETRY_MARKER" in json.dumps(messages) and "add" in results and not state.get("retry_injected"): + state["retry_injected"] = True + return JSONResponse({"error": {"message": "Temporary fixture failure", "type": "server_error"}}, status_code=503) + chain = [ + ("add", {"a": 2, "b": 3}), + ("multiply", {"a": 5, "b": 4}), + ("read_skill_md", {"skill_name": "fixture_skill"}), + ("run_skill_script", {"skill_name": "fixture_skill", "script_path": "scripts/hello.py"}), + ] + remaining = [(name, args) for name, args in chain if name not in results] + if "CURRENT_TASK_ONLY_MARKER" in json.dumps(messages[-1]): + assert "Earlier conversation messages, attachments and results are historical reference" in json.dumps(messages) + delta, reason = {"role": "assistant", "content": "CURRENT_RESPONSE_MARKER"}, "stop" + elif remaining: + name, args = remaining[0] + delta = {"role": "assistant", "content": "", "tool_calls": [{"index": 0, "id": f"fixture_{name}", + "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}]} + reason = "tool_calls" + else: + assert "5" in results["add"], results + assert "20" in results["multiply"], results + assert "SKILL_GUIDE_MARKER" in results["read_skill_md"], results + assert "SCRIPT_RESULT_MARKER" in results["run_skill_script"], results + delta, reason = {"role": "assistant", "content": "native_result_20"}, "stop" + chunk = {"id": "fixture-completion", "object": "chat.completion.chunk", "created": int(time.time()), + "model": body["model"], "choices": [{"index": 0, "delta": delta, "finish_reason": reason}], + "usage": {"prompt_tokens": 100, "completion_tokens": 10, "total_tokens": 110}} + if body.get("stream"): + return StreamingResponse(iter([f"data: {json.dumps(chunk)}\n\n", "data: [DONE]\n\n"]), + media_type="text/event-stream") + chunk["choices"][0]["message"] = chunk["choices"][0].pop("delta") + for tool in chunk["choices"][0]["message"].get("tool_calls", []): + tool.pop("index", None) + chunk["object"] = "chat.completion" + return JSONResponse(chunk) + + +async def status(request): + return JSONResponse(state) + + +mcp_app = mcp.http_app(path="/mcp") + + +@asynccontextmanager +async def lifespan(app): + async with mcp_app.lifespan(app): + yield + + +app = Starlette(routes=[Route("/v1/chat/completions", completions, methods=["POST"]), + Route("/state", status), Mount("/", app=mcp_app)], lifespan=lifespan) + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[1]), log_level="warning", timeout_graceful_shutdown=1) diff --git a/test/automation/d3/test_agent_core_factory_native.py b/test/automation/d3/test_agent_core_factory_native.py new file mode 100644 index 0000000000..8e2ff6ebc5 --- /dev/null +++ b/test/automation/d3/test_agent_core_factory_native.py @@ -0,0 +1,167 @@ +"""AGENT-CORE-D3-001: Native Jiuwen execution through the Nexent managed worker.""" + +import asyncio +import json +import socket +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import httpx +import pytest + +from nexent.core.agents.agent_model import AgentConfig, AgentHistory, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.context.models import ContextItemInput, ContextItemType +from nexent.core.agents.context_input import ContextInput +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import LanePolicy, ThreadManager +from nexent.core.utils.observer import MessageObserver + + +@pytest.fixture +def native_manager(): + manager = ThreadManager("jiuwen-tests", {name: LanePolicy(name=name, max_workers=4, max_queue_size=8) + for name in ["agent-run", "model-tool-io", "mcp-session"]}) + manager.start() + yield manager + asyncio.run(manager.shutdown(timeout=5)) + + +@pytest.fixture +def native_provider(tmp_path): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + with (tmp_path / "provider.log").open("w", encoding="utf-8") as log: + process = subprocess.Popen([sys.executable, str(Path(__file__).with_name("jiuwen_http_fixture.py")), str(port)], + stdout=log, stderr=log) + url = f"http://127.0.0.1:{port}" + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if process.poll() is not None: + pytest.fail((tmp_path / "provider.log").read_text(encoding="utf-8")) + try: + if httpx.get(url + "/state", timeout=0.2, trust_env=False).status_code == 200: + break + except httpx.HTTPError: + time.sleep(0.05) + else: + pytest.fail("Local Jiuwen provider did not become ready") + yield url + finally: + process.terminate() + process.wait(timeout=5) + + +@pytest.mark.parametrize("snapshot_history", [False, True]) +@pytest.mark.parametrize("transient_failure", [False, True]) +def test_AGENT_CORE_D3_001_native_worker( + native_manager, native_provider, tmp_path, monkeypatch, snapshot_history, transient_failure, +): + from openjiuwen.core.runner import Runner + + from nexent.core.agents import nexent_agent + from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + + calls = [] + + class NativeAddTool: + name, description = "add", "Add integers" + inputs = {"a": {"type": "integer"}, "b": {"type": "integer"}} + + def forward(self, a, b): + calls.append((a, b)) + return a + b + + monkeypatch.setattr(nexent_agent, "NativeAddTool", NativeAddTool, raising=False) + monkeypatch.setattr(nexent_agent.NexentAgent, "create_single_agent", + lambda *args, **kwargs: pytest.fail("Jiuwen invoked a smolagents Agent")) + owned_cards = [] + original_prepare = JiuwenAdapter.prepare + + def track_prepare(adapter): + original_prepare(adapter) + owned_cards.extend(card.id for card in adapter.native_agent.ability_manager.list()) + + monkeypatch.setattr(JiuwenAdapter, "prepare", track_prepare) + skill = tmp_path / "skills" / "fixture_skill" + (skill / "scripts").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: fixture_skill\ndescription: Fixture skill guide\n---\nSKILL_GUIDE_MARKER\n", + encoding="utf-8") + (skill / "scripts" / "hello.py").write_text("print('SCRIPT_RESULT_MARKER')\n", encoding="utf-8") + parameters = {"local_skills_dir": str(skill.parent), "authorized_skill_names": ["fixture_skill"], + "isolated_skills_root": True} + observer = MessageObserver(lang="en") + info = AgentRunInfo(query="Execute the authorized tool chain", observer=observer, stop_event=__import__("threading").Event(), + thread_manager=native_manager, model_config_list=[ModelConfig(cite_name="fixture", + model_name="fixture-model", api_key=uuid.uuid4().hex, url=native_provider + "/v1", + timeout_seconds=5, max_output_tokens=512)], + mcp_host=[native_provider + "/mcp"], mcp_request_timeout_seconds=5, + user_context={"user_id": "fixture-user"}, + agent_config=AgentConfig(agent_core="jiuwen", name="native-fixture", description="Fixture agent", + model_name="fixture", max_steps=6, instructions="Preserve business instructions.", tools=[ + ToolConfig(name="add", class_name="NativeAddTool", source="local", params={}), + ToolConfig(name="multiply", class_name="multiply", source="mcp", params={}), + ToolConfig(name="read_skill_md", class_name="ReadSkillMdTool", source="builtin", params=parameters), + ToolConfig(name="run_skill_script", class_name="RunSkillScriptTool", source="builtin", params=parameters), + ])) + + info.history = [AgentHistory(role="user", content="Earlier question HISTORY_USER_MARKER"), + AgentHistory(role="assistant", content="Earlier answer HISTORY_ASSISTANT_MARKER")] + if snapshot_history: + info.context_input = ContextInput(items=(ContextItemInput( + id="history:turn:1", type=ContextItemType.CONVERSATION_TURN, + content={"user_message": info.history[0].content, "assistant_final_answer": info.history[1].content, + "user_message_id": 1, "assistant_message_id": 2}, + ),)) + info.history = [AgentHistory(role="user", content="UNAUTHORIZED_HISTORY_MARKER")] + if transient_failure: + info.query += " TRANSIENT_RETRY_MARKER" + + async def run(): + return [message async for message in agent_run(info, thread_manager=native_manager)] + + output = asyncio.run(run()) + state = httpx.get(native_provider + "/state", trust_env=False).json() + assert info.attempt_outcome == "completed", (output[-1:], state) + assert calls == [(2, 3)] + expected_calls = 6 if transient_failure else 5 + assert state["mcp_calls"] == 1 and state["model_calls"] == expected_calls + assert state["user_id"] == "fixture-user" + assert state["saw_skill"] + messages = state["messages"] + serialized = json.dumps(messages) + assert serialized.count("HISTORY_USER_MARKER") == 1 + assert serialized.count("HISTORY_ASSISTANT_MARKER") == 1 + assert "UNAUTHORIZED_HISTORY_MARKER" not in serialized + assert messages[0]["role"] == "system" + assert serialized.count(info.query) == 1 + assert "forbidden_tool" not in state["tools"] and "bash" not in state["tools"] + assert all(Runner.resource_mgr.get_tool(card_id) is None for card_id in owned_cards) + decoded = [json.loads(message) for message in output] + final = [message for message in decoded if message.get("type") == "final_answer"] + assert len(final) == 1 and "native_result_20" in str(final) + assert any(message.get("type") == "tool" for message in decoded) + assert sum(message.get("type") == "step_count" for message in decoded) == expected_calls + + # A new turn may use the completed result without re-running the old tool chain. + follow_up = info.model_copy(update={ + "query": "What can you do? CURRENT_TASK_ONLY_MARKER", "observer": MessageObserver(lang="en"), + "context_input": None, + "history": [AgentHistory(role="user", content=info.query), + AgentHistory(role="assistant", content="native_result_20")], + }) + + async def run_follow_up(): + return [message async for message in agent_run(follow_up, thread_manager=native_manager)] + + second_output = asyncio.run(run_follow_up()) + second_state = httpx.get(native_provider + "/state", trust_env=False).json() + assert follow_up.attempt_outcome == "completed", second_output[-1:] + assert calls == [(2, 3)] and second_state["mcp_calls"] == 1 + assert second_state["model_calls"] == expected_calls + 1 + assert any("CURRENT_RESPONSE_MARKER" in message for message in second_output) + assert all(Runner.resource_mgr.get_tool(card_id) is None for card_id in owned_cards) diff --git a/test/automation/d5/test_agent_core_factory_reliability.py b/test/automation/d5/test_agent_core_factory_reliability.py new file mode 100644 index 0000000000..ef61f76e57 --- /dev/null +++ b/test/automation/d5/test_agent_core_factory_reliability.py @@ -0,0 +1,122 @@ +"""AGENT-CORE-D5-001: Cancellation, MCP deadlines and native resource isolation.""" + +import asyncio +import json +import time +from threading import Event + +import httpx +import pytest + +from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.run_agent import agent_run +from nexent.core.concurrency import RunCancellationScope +from nexent.core.utils.observer import MessageObserver + +from test.automation.d3.test_agent_core_factory_native import native_manager, native_provider # noqa: F401 + + +def make_run(manager, url, query="block_model"): + stop = Event() + return AgentRunInfo(query=query, observer=MessageObserver(lang="en"), stop_event=stop, + cancellation_scope=RunCancellationScope(stop), thread_manager=manager, + model_config_list=[ModelConfig(cite_name="fixture", model_name="fixture-model", url=url + "/v1", + timeout_seconds=5)], + agent_config=AgentConfig(agent_core="jiuwen", name="same-name", description="Fixture", + model_name="fixture", tools=[])) + + +@pytest.mark.parametrize("status,attempts", [(400, 1), (503, 2)]) +def test_native_provider_failure_is_bounded(native_manager, native_provider, status, attempts): + info = make_run(native_manager, native_provider, f"FAIL_MODEL_{status}") + + async def consume(): + return [message async for message in agent_run(info, thread_manager=native_manager)] + + output = asyncio.run(consume()) + state = httpx.get(native_provider + "/state", trust_env=False).json() + assert info.attempt_outcome == "failed" + assert state["model_calls"] == attempts and state["mcp_calls"] == 0 + assert not any("native_result_20" in message for message in output) + + +def test_AGENT_CORE_D5_001_cancellation_deadline_and_isolation(native_manager, native_provider): # noqa: F811 + from openjiuwen.core.foundation.tool import LocalFunction, ToolCard + from openjiuwen.core.runner import Runner + + from nexent.consts.mcp_errors import MCPToolTimeoutError + from nexent.core.agents.core_adapters.mcp import JiuwenMCPCollection + + info = make_run(native_manager, native_provider) + + async def cancel_blocked_model(): + async def consume(): + return [message async for message in agent_run(info, thread_manager=native_manager)] + + consumer = asyncio.create_task(consume()) + async with httpx.AsyncClient(trust_env=False) as client: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if (await client.get(native_provider + "/state")).json()["model_calls"]: + break + await asyncio.sleep(0.05) + else: + raise AssertionError("Native model never started") + started = time.monotonic() + info.cancellation_scope.cancel() + output = await asyncio.wait_for(consumer, 5) + assert time.monotonic() - started < 5 + assert info.attempt_outcome == "stopped" + assert not any(json.loads(message).get("type") == "final_answer" for message in output) + + asyncio.run(cancel_blocked_model()) + first, second = JiuwenAdapter(make_run(native_manager, native_provider, "fixture")), JiuwenAdapter( + make_run(native_manager, native_provider, "fixture")) + try: + for adapter in [first, second]: + adapter.validate() + adapter.prepare() + for index, adapter in enumerate([first, second]): + card = ToolCard(name="same_tool", description="Isolated tool", input_params={"type": "object"}) + + async def value(_index=index): + return _index + + adapter.native_agent.ability_manager.add_ability(card, LocalFunction(card, value)) + first_card = first.native_agent.ability_manager.get("same_tool") + second_card = second.native_agent.ability_manager.get("same_tool") + assert first_card.id != second_card.id + assert asyncio.run(Runner.resource_mgr.get_tool(first_card.id).invoke({})) == 0 + assert asyncio.run(Runner.resource_mgr.get_tool(second_card.id).invoke({})) == 1 + first.close() + assert Runner.resource_mgr.get_tool(first_card.id) is None + assert Runner.resource_mgr.get_tool(second_card.id) is not None + finally: + first.close() + second.close() + + collection = JiuwenMCPCollection( + authorized_tools=[ToolConfig(name="multiply", class_name="multiply", source="mcp", params={})], + observe=lambda *args: None, manager=native_manager, server_parameters=[native_provider + "/mcp"], + cancellation_scope=RunCancellationScope(), tool_timeout_seconds=0.2, close_timeout_seconds=2, + connect_timeout_seconds=5, user_context={"user_id": "fixture-user"}, + ) + with collection as connected: + async def timeout_call(): + with pytest.raises(Exception): + await connected.tools[0].invoke({"a": 2, "b": 2, "user_id": "forged-user"}) + assert collection.terminal_error is None + try: + await connected.tools[0].invoke({"a": 999, "b": 2}) + except Exception: + assert isinstance(collection.terminal_error, MCPToolTimeoutError) + else: + raise AssertionError("Stalled MCP call did not time out") + + asyncio.run(timeout_call()) + assert not collection._active_calls + assert collection._execution.future.done() + assert collection._loop is None + state = httpx.get(native_provider + "/state", trust_env=False).json() + assert state["user_id"] == "fixture-user" diff --git a/test/backend/adapters/test_jiuwen_sdk_adapter.py b/test/backend/adapters/test_jiuwen_sdk_adapter.py index a867a5c361..7da0c4754a 100644 --- a/test/backend/adapters/test_jiuwen_sdk_adapter.py +++ b/test/backend/adapters/test_jiuwen_sdk_adapter.py @@ -113,7 +113,7 @@ def __init__(self, case=None, answer=None, score=0.0, reason=""): # multiple times inside the same pytest session without leaking state. # --------------------------------------------------------------------------- @pytest.fixture -def adapter_module(monkeypatch): +def adapter_module(monkeypatch, request): """Import ``adapters.jiuwen_sdk_adapter`` fresh for each test. The module installs a meta-path finder and writes module-level stubs on @@ -126,6 +126,7 @@ def adapter_module(monkeypatch): # Snapshot of the stub-tree entries we manage. We re-register them # before every test, even if the test deleted them. _ensure_openjiuwen_stub() + monkeypatch.setattr("importlib.metadata.version", lambda name: getattr(request, "param", "0.1.13")) sys.modules.pop("adapters.jiuwen_sdk_adapter", None) import adapters.jiuwen_sdk_adapter as mod # noqa: E402,WPS433 @@ -151,6 +152,12 @@ def test_bypasser_installed_flag_is_true(self, adapter_module): # The module marks the bypasser as installed at import time. assert adapter_module._bypasser_installed is True + @pytest.mark.parametrize("adapter_module", ["0.1.18"], indirect=True) + def test_native_version_does_not_install_legacy_import_shims(self, adapter_module): + assert adapter_module._needs_legacy_bypass is False + assert adapter_module._bypasser_installed is False + assert not any(isinstance(finder, adapter_module._JiuwenInitBypasser) for finder in sys.meta_path) + def test_install_jiuwen_bypasser_is_now_a_noop(self, adapter_module): # After the import-time install, calling the helper is a no-op that # returns True. This guards the backwards-compat shim. diff --git a/test/backend/agents/test_child_model_binding.py b/test/backend/agents/test_child_model_binding.py new file mode 100644 index 0000000000..e0b30b6b89 --- /dev/null +++ b/test/backend/agents/test_child_model_binding.py @@ -0,0 +1,37 @@ +"""Capture child version options before parent request overlays.""" + +import pytest +from agents import create_agent_info as service +from nexent.core.agents.agent_model import AgentConfig, ModelConfig + + +def config(name, core="jiuwen", children=None): + return AgentConfig(name=name, description=name, model_name="shared", tools=[], agent_core=core, + worker_agents=children or [], agent_id=2, version_no=3) + + +def test_child_and_grandchild_capture_saved_options_without_parent_overlay(mocker): + grandchild = config("grandchild", "smolagents") + child = config("child", children=[grandchild]) + root = config("parent", children=[child]) + models = [ModelConfig(cite_name="shared", model_name="model", url="http://saved", temperature=0.5)] + lookup = mocker.patch.object(service, "search_agent_info_by_agent_id", return_value={ + "model_params_override": {"7": {"temperature": 0.2, "extra_params": {"__custom__": {"saved": 1}}}}}) + mocker.patch.object(service, "get_model_by_model_id", return_value={"display_name": "shared"}) + compile_profile = mocker.patch("services.model_runtime_options_service.compile_model_runtime_profile", + return_value=None) + service._capture_child_model_configs(root, models, "tenant") + models[0].temperature = 0.9 + assert child.runtime_model_config.temperature == grandchild.runtime_model_config.temperature == 0.2 + assert child.runtime_model_config.extra_body == {"saved": 1} + child.runtime_model_config.extra_body["saved"] = 2 + assert grandchild.runtime_model_config.extra_body == {"saved": 1} + assert lookup.call_count == 2 + assert lookup.call_args.kwargs == {"agent_id": 2, "tenant_id": "tenant", "version_no": 3} + compile_profile.assert_called_once() + + +def test_missing_child_model_fails_before_runtime(mocker): + mocker.patch.object(service, "search_agent_info_by_agent_id", return_value={}) + with pytest.raises(ValueError, match="CHILD_MODEL_OUTLET_NOT_FOUND"): + service._capture_child_model_configs(config("root", children=[config("child")]), [], "tenant") diff --git a/test/backend/agents/test_jiuwen_cm_policy.py b/test/backend/agents/test_jiuwen_cm_policy.py new file mode 100644 index 0000000000..ff106b9abf --- /dev/null +++ b/test/backend/agents/test_jiuwen_cm_policy.py @@ -0,0 +1,17 @@ +"""The saved CM checkbox is authoritative at Agent policy precedence.""" + +import pytest +from agents.create_agent_info import build_agent_context_policy +from nexent.core.agents.context.policy import resolve_policy + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_saved_switch_overrides_tenant_and_stale_agent_mode(enabled): + opposite = "passthrough" if enabled else "adaptive_compact" + layers = build_agent_context_policy(enabled, {"processing_mode": opposite}, {"processing_mode": opposite}) + assert resolve_policy(layers).processing_mode.value == ("adaptive_compact" if enabled else "passthrough") + + +def test_explicit_request_policy_keeps_existing_highest_precedence(): + layers = build_agent_context_policy(True, request_policy={"processing_mode": "passthrough"}) + assert resolve_policy(layers).processing_mode.value == "passthrough" diff --git a/test/backend/agents/test_jiuwen_current_task.py b/test/backend/agents/test_jiuwen_current_task.py new file mode 100644 index 0000000000..a15d1f26cb --- /dev/null +++ b/test/backend/agents/test_jiuwen_current_task.py @@ -0,0 +1,47 @@ +"""Keep historical attachments out of the native core's active task.""" + +from unittest.mock import AsyncMock + +import pytest + +from agents import create_agent_info as service +from consts.model import HistoryItem +from nexent.core.agents.agent_model import AgentConfig, ModelConfig + + +@pytest.mark.parametrize("core,current_upload", [("jiuwen", False), ("jiuwen", True), ("smolagents", False)]) +async def test_current_query_preserves_history_without_reactivating_uploads(mocker, core, current_upload): + old_file = {"name": "old-image.png", "url": "s3://nexent/attachments/old-image.png"} + new_file = {"name": "new-image.png", "url": "s3://nexent/attachments/new-image.png"} + history = [HistoryItem(role="user", content="Analyze the original image", minio_files=[old_file]), + HistoryItem(role="assistant", content="The image has already been analyzed")] + mocker.patch("services.model_runtime_options_service.compile_model_runtime_profile", return_value=None) + config = AgentConfig(agent_core=core, name="fixture", description="Fixture", model_name="fixture", tools=[]) + model = ModelConfig(cite_name="fixture", model_name="fixture", url="http://fixture") + for name in ["_ensure_agent_reasoning_snapshot", "_validate_run_minio_files"]: + mocker.patch.object(service, name) + mocker.patch.object(service, "_build_run_workspace", return_value="/fixture/workspace") + mocker.patch.object(service, "create_agent_config", new=AsyncMock(return_value=config)) + mocker.patch.object(service, "create_model_config_list", new=AsyncMock(return_value=[model])) + mocker.patch.object(service, "search_agent_info_by_agent_id", return_value={}) + mocker.patch.object(service, "get_remote_mcp_server_list", new=AsyncMock(return_value=[])) + mocker.patch.object(service, "filter_mcp_servers_and_tools", return_value=[]) + mocker.patch.object(service, "get_tenant_local_mcp_server", return_value="http://fixture/mcp") + mocker.patch.object(service, "get_redis_client", return_value=None) + mocker.patch("agents.sandbox_config.resolve_sandbox_config", return_value=None) + mocker.patch("management.services.agent.service.build_sandbox_policy", return_value=None) + info = await service.create_agent_run_info( + agent_id=1, minio_files=[new_file] if current_upload else [], query="What can you do?", + history=history, user_id="fixture-user", tenant_id="fixture-tenant", language="en", is_debug=True, + ) + assert "What can you do?" in info.query + assert "old-image.png" in info.history[0].content + assert len(info.minio_files) == (2 if current_upload else 1) + if core == "jiuwen": + assert "old-image.png" not in info.query + if current_upload: + assert "new-image.png" in info.query + else: + assert info.query == "What can you do?" + else: + assert "old-image.png" in info.query diff --git a/test/backend/database/test_conversation_append_order.py b/test/backend/database/test_conversation_append_order.py new file mode 100644 index 0000000000..b72f842a18 --- /dev/null +++ b/test/backend/database/test_conversation_append_order.py @@ -0,0 +1,38 @@ +"""Database-owned turn indices, independent of frontend message counts.""" +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def store(monkeypatch): + import database.conversation_db as module + session = MagicMock() + @contextmanager + def current(): + yield session + monkeypatch.setattr(module, "get_db_session", current) + lock, create = MagicMock(), MagicMock(return_value=401) + monkeypatch.setattr(module, "_lock_conversation_limit_scope", lock) + monkeypatch.setattr(module, "create_conversation_message", create) + return module, session, lock, create + + +@pytest.mark.parametrize("latest,expected", [(None, 0), (0, 2), (1, 2), (5, 6), (8, 10)]) +def test_append_reserves_the_adjacent_assistant_index(store, latest, expected): + module, session, lock, create = store + session.scalar.side_effect = [7, latest] + assert module.append_conversation_user_message(7, "Current", [], "owner") == (401, expected) + lock.assert_called_once_with(session, "conversation-turn-limit:7") + args, kwargs = create.call_args + assert args[0]["message_idx"] == expected and args[0]["role"] == "user" + assert args[1] == "owner" and kwargs["_session"] is session + + +def test_unowned_conversation_never_inserts(store): + module, session, _lock, create = store + session.scalar.return_value = None + with pytest.raises(module.ConversationNotFoundError): + module.append_conversation_user_message(7, "Current", [], "other") + create.assert_not_called() diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index 16d59df9ef..6ed6b2f63b 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -3650,8 +3650,10 @@ def test_get_historical_context_returns_latest_summary_and_only_new_turns( SimpleNamespace(message_id=32, message_index=5, message_role="assistant", message_content="new answer", minio_files=None, status="completed"), ] + records_result = MagicMock() + records_result.all.return_value = [] session.execute.side_effect = [ - current_result, candidates_result, boundary_result, messages_result] + current_result, candidates_result, boundary_result, messages_result, records_result] monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx) result = get_historical_context(1, 25, "user-a", "tenant-a") diff --git a/test/backend/database/test_conversation_history_stopped.py b/test/backend/database/test_conversation_history_stopped.py index 4d1761114a..11295c228e 100644 --- a/test/backend/database/test_conversation_history_stopped.py +++ b/test/backend/database/test_conversation_history_stopped.py @@ -19,7 +19,7 @@ def history_db(monkeypatch): connection.exec_driver_sql("ATTACH DATABASE ':memory:' AS nexent") connection.exec_driver_sql("CREATE TABLE nexent.conversation_record_t (conversation_id INTEGER PRIMARY KEY, created_by TEXT, delete_flag TEXT)") connection.exec_driver_sql("CREATE TABLE nexent.conversation_message_t (message_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_index INTEGER, message_role TEXT, message_content TEXT, minio_files TEXT, status TEXT, delete_flag TEXT)") - connection.exec_driver_sql("CREATE TABLE nexent.conversation_message_unit_t (unit_id INTEGER PRIMARY KEY, message_id INTEGER, conversation_id INTEGER, unit_index INTEGER, unit_type TEXT, unit_content TEXT, unit_status TEXT, delete_flag TEXT)") + connection.exec_driver_sql("CREATE TABLE nexent.conversation_message_unit_t (unit_id INTEGER PRIMARY KEY, message_id INTEGER, conversation_id INTEGER, unit_index INTEGER, unit_type TEXT, unit_content TEXT, unit_status TEXT, delete_flag TEXT, tool_call_id TEXT, invocation_id TEXT)") connection.exec_driver_sql("INSERT INTO nexent.conversation_record_t VALUES (1, 'owner', 'N')") @contextmanager @@ -37,7 +37,7 @@ def message(mid, role, content, status="completed", files=None, deleted="N"): def unit(uid, mid, kind, content, status="completed", deleted="N"): with engine.begin() as connection: - connection.execute(text("INSERT INTO nexent.conversation_message_unit_t VALUES (:id, :mid, 1, :id, :kind, :body, :status, :deleted)"), + connection.execute(text("INSERT INTO nexent.conversation_message_unit_t (unit_id, message_id, conversation_id, unit_index, unit_type, unit_content, unit_status, delete_flag) VALUES (:id, :mid, 1, :id, :kind, :body, :status, :deleted)"), {"id": uid, "mid": mid, "kind": kind, "body": content, "status": status, "deleted": deleted}) yield module, message, unit @@ -104,3 +104,22 @@ def test_projection_is_bounded_and_excludes_deleted_or_future_rows(history_db): turns = module.get_historical_context(1, 3, "owner", "tenant")["conversation_turns"] assert len(turns) == 1 assert len(turns[0]["assistant_final_answer"]) < 16100 + + +def test_completed_history_keeps_only_persisted_execution_evidence(history_db): + module, message, unit = history_db + message(1, "user", "Use the old file", files=[{"name": "old.txt", "url": "s3://files/old.txt"}]) + message(2, "assistant", "Completed") + message(3, "user", "Current task") + unit(1, 2, "tool", '{"tool_name":"old_tool","tool_arguments":{"a":1}}') + unit(2, 2, "execution_logs", "Saved result") + unit(3, 2, "error", "Saved failure") + unit(4, 2, "parse", "Never replay code") + unit(5, 2, "execution_logs", "Not committed", status="streaming") + unit(6, 2, "error", "Deleted error", deleted="Y") + projected = module.get_historical_context(1, 3, "owner", "tenant")["conversation_turns"][0] + records = projected["execution_records"] + assert [record["record_type"] for record in records] == ["tool", "execution_logs", "error"] + assert "Saved result" in str(records) and "Saved failure" in str(records) + assert all(text not in str(records) for text in ["Never replay", "Not committed", "Deleted error"]) + assert "s3://files/old.txt" in projected["attachments"] diff --git a/test/backend/services/test_agent_core_creation.py b/test/backend/services/test_agent_core_creation.py new file mode 100644 index 0000000000..3452bd9aed --- /dev/null +++ b/test/backend/services/test_agent_core_creation.py @@ -0,0 +1,50 @@ +"""Regression coverage for the Agent creation context policy contract.""" + +from unittest.mock import MagicMock + +import pytest +from pydantic import ValidationError + +from consts.model import AgentInfoRequest +from management.services.agent import service + + +@pytest.fixture +def create_boundary(monkeypatch): + monkeypatch.setattr(service, "get_current_user_info", lambda _: ("user", "tenant", "DEV")) + monkeypatch.setattr(service, "get_prompt_template_summary", lambda **_: (0, "system_default")) + monkeypatch.setattr(service, "build_reasoning_snapshot", lambda **_: None) + monkeypatch.setattr(service, "_get_user_group_ids", lambda *_: "") + monkeypatch.setattr(service, "query_all_agent_info_by_tenant_id", lambda *_: []) + create = MagicMock(return_value={"agent_id": 42}) + monkeypatch.setattr(service, "create_agent", create) + return create + + +@pytest.mark.asyncio +@pytest.mark.parametrize("core", ["smolagents", "jiuwen"]) +@pytest.mark.parametrize("flag,expected", [(None, True), (False, False), (True, True)]) +async def test_create_preserves_context_manager_flag(create_boundary, core, flag, expected): + payload = {"name": "context-test", "display_name": "Context test", "agent_core": core} + if flag is not None: + payload["enable_context_manager"] = flag + result = await service.update_agent_info_impl(AgentInfoRequest(**payload), "Bearer test") + assert result["agent_id"] == 42 + config = create_boundary.call_args.kwargs["agent_info"] + assert config["enable_context_manager"] is expected + assert config["agent_core"] == core + + +@pytest.mark.asyncio +async def test_create_database_failure_is_not_reported_as_success(create_boundary): + create_boundary.side_effect = RuntimeError("database unavailable") + with pytest.raises(ValueError, match="database unavailable"): + await service.update_agent_info_impl( + AgentInfoRequest(name="test", display_name="Test", agent_core="jiuwen", enable_context_manager=False), "Bearer test" + ) + + +def test_unknown_core_is_rejected_before_creation(create_boundary): + with pytest.raises(ValidationError): + AgentInfoRequest(agent_core="unknown") + create_boundary.assert_not_called() diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py index 2f5c258a32..717c451521 100644 --- a/test/backend/services/test_agent_service.py +++ b/test/backend/services/test_agent_service.py @@ -5257,6 +5257,14 @@ def mock_http_request(): return Request(scope={"type": "http", "headers": []}) +@pytest.fixture +def authorized_history_boundary(monkeypatch): + monkeypatch.setattr("management.services.agent.run.get_current_run_user_message_id", lambda *a: 11) + monkeypatch.setattr("management.services.agent.run.load_historical_context", lambda *a: { + "conversation_turns": [], "history_summary": None, + }) + + @pytest.mark.asyncio @patch("management.services.agent.run.build_memory_context") @patch("management.services.agent.run.create_agent_run_info", new_callable=AsyncMock) @@ -5267,6 +5275,7 @@ async def test_prepare_agent_run( mock_build_memory_context, mock_agent_request, mock_http_request, + authorized_history_boundary, ): """Test prepare_agent_run function.""" # Setup @@ -5333,6 +5342,7 @@ async def test_prepare_agent_run_can_disable_automation_tool( mock_create_run_info, mock_build_memory_context, mock_agent_request, + authorized_history_boundary, ): mock_agent_request.enable_automation_tool = False mock_run_info = MagicMock() @@ -6977,6 +6987,12 @@ async def test_generate_stream_registers_and_streams(monkeypatch): AsyncMock(return_value=MagicMock()), ) + # Orchestration starts from an already saved, authorized database boundary. + monkeypatch.setattr(agent_run_service, "get_current_run_user_message_id", lambda *_: 13) + monkeypatch.setattr(agent_run_service, "load_historical_context", lambda *_: { + "history_summary": None, "conversation_turns": [], "current_user_message_index": 8, + }) + registered = {} def fake_register(conv_id, run_info, user_id, **kwargs): @@ -7016,6 +7032,7 @@ async def fake_stream_chunks(*_, **__): ): collected.append(d) + assert agent_request._runtime_user_message_index == 8 assert registered.get("conv_id") == 555 assert registered.get("user_id") == "u" assert registered.get("run_info") is not None diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index baf1fa85f8..b2c6cdd883 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -379,33 +379,18 @@ def test_save_source_search_passes_through(self, mock_create_source_search): self.assertEqual(save_source_search(search_data, user_id="u"), 7) mock_create_source_search.assert_called_once_with(search_data, user_id="u") - @patch('backend.services.conversation_management_service.save_message') - def test_save_conversation_user(self, mock_save_message): - """User messages only create a message row, no unit records are created.""" - mock_save_message.return_value = 999 + @patch('backend.services.conversation_management_service.append_conversation_user_message') + def test_save_conversation_user(self, mock_append): + """User messages use the database-reserved pair and create no unit records.""" + mock_append.return_value = (999, 2) agent_request = AgentRequest( - conversation_id=123, - query="What is machine learning?", - minio_files=[], - history=[ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi there"} - ] + conversation_id=123, query="What is machine learning?", minio_files=[], + history=[{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there"}], ) - - # Execute save_conversation_user(agent_request, self.user_id, self.tenant_id) - - # Assert: save_message is called exactly once (no unit records for user messages) - mock_save_message.assert_called_once() - request_arg = mock_save_message.call_args[0][0] - self.assertEqual(request_arg.conversation_id, 123) - # Based on 1 user message in history - self.assertEqual(request_arg.message_idx, 2) - self.assertEqual(request_arg.role, "user") - self.assertEqual(request_arg.message[0].type, "string") - self.assertEqual( - request_arg.message[0].content, "What is machine learning?") + mock_append.assert_called_once_with(123, "What is machine learning?", [], self.user_id) + self.assertEqual(agent_request._runtime_user_message_id, 999) + self.assertEqual(agent_request._runtime_user_message_index, 2) def test_save_conversation_assistant_is_removed(self): """save_conversation_assistant has been replaced by the incremental @@ -420,10 +405,10 @@ def test_save_conversation_assistant_is_removed(self): save_conversation_assistant( agent_request, [], self.user_id, self.tenant_id) - @patch('backend.services.conversation_management_service.save_message') + @patch('backend.services.conversation_management_service.append_conversation_user_message') def test_save_conversation_user_strips_runtime_time_marker(self, mock_save_message): """Remove the appended runtime time marker before persisting the user request.""" - mock_save_message.return_value = 1001 + mock_save_message.return_value = (1001, 0) agent_request = AgentRequest( conversation_id=456, query="What is the weather?\n\n[Current time: 2026-01-01 20:00:00]", @@ -434,13 +419,13 @@ def test_save_conversation_user_strips_runtime_time_marker(self, mock_save_messa save_conversation_user(agent_request, self.user_id, self.tenant_id) mock_save_message.assert_called_once() - request_arg = mock_save_message.call_args[0][0] - self.assertEqual(request_arg.message[0].content, "What is the weather?") + saved_content = mock_save_message.call_args[0][1] + self.assertEqual(saved_content, "What is the weather?") - @patch('backend.services.conversation_management_service.save_message') + @patch('backend.services.conversation_management_service.append_conversation_user_message') def test_save_conversation_user_without_prefix_unchanged(self, mock_save_message): """When query has no [Current time: ...] prefix, it should be persisted as-is.""" - mock_save_message.return_value = 1002 + mock_save_message.return_value = (1002, 0) agent_request = AgentRequest( conversation_id=456, query="What is machine learning?", @@ -451,13 +436,13 @@ def test_save_conversation_user_without_prefix_unchanged(self, mock_save_message save_conversation_user(agent_request, self.user_id, self.tenant_id) mock_save_message.assert_called_once() - request_arg = mock_save_message.call_args[0][0] - self.assertEqual(request_arg.message[0].content, "What is machine learning?") + saved_content = mock_save_message.call_args[0][1] + self.assertEqual(saved_content, "What is machine learning?") - @patch('backend.services.conversation_management_service.save_message') + @patch('backend.services.conversation_management_service.append_conversation_user_message') def test_save_conversation_user_empty_query(self, mock_save_message): """When query is empty, it should be persisted as-is without errors.""" - mock_save_message.return_value = 1003 + mock_save_message.return_value = (1003, 0) agent_request = AgentRequest( conversation_id=456, query="", @@ -468,13 +453,13 @@ def test_save_conversation_user_empty_query(self, mock_save_message): save_conversation_user(agent_request, self.user_id, self.tenant_id) mock_save_message.assert_called_once() - request_arg = mock_save_message.call_args[0][0] - self.assertEqual(request_arg.message[0].content, "") + saved_content = mock_save_message.call_args[0][1] + self.assertEqual(saved_content, "") - @patch('backend.services.conversation_management_service.save_message') + @patch('backend.services.conversation_management_service.append_conversation_user_message') def test_save_conversation_user_malformed_prefix_no_bracket(self, mock_save_message): """When query has [Current time: prefix but no closing bracket, persist as-is.""" - mock_save_message.return_value = 1004 + mock_save_message.return_value = (1004, 0) agent_request = AgentRequest( conversation_id=456, query="[Current time: no closing bracket here", @@ -485,8 +470,8 @@ def test_save_conversation_user_malformed_prefix_no_bracket(self, mock_save_mess save_conversation_user(agent_request, self.user_id, self.tenant_id) mock_save_message.assert_called_once() - request_arg = mock_save_message.call_args[0][0] - self.assertEqual(request_arg.message[0].content, "[Current time: no closing bracket here") + saved_content = mock_save_message.call_args[0][1] + self.assertEqual(saved_content, "[Current time: no closing bracket here") @patch('backend.services.conversation_management_service.get_llm_adapter_from_config') @patch('backend.services.conversation_management_service.compose_auxiliary_prompt') diff --git a/test/backend/services/test_conversation_runtime_boundary.py b/test/backend/services/test_conversation_runtime_boundary.py new file mode 100644 index 0000000000..d3cec24136 --- /dev/null +++ b/test/backend/services/test_conversation_runtime_boundary.py @@ -0,0 +1,26 @@ +"""Bind server-saved message boundaries without trusting the client history.""" +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + + +@pytest.mark.parametrize("history", [None, [], [{"role": "user", "content": "Forged old turn"}]]) +def test_user_save_uses_database_index(monkeypatch, history): + import services.conversation_management_service as module + append = Mock(return_value=(401, 10)) + monkeypatch.setattr(module, "append_conversation_user_message", append) + request = SimpleNamespace(conversation_id=7, query="Current", history=history, minio_files=[]) + assert module.save_conversation_user(request, "owner", "tenant") is None + append.assert_called_once_with(7, "Current", [], "owner") + assert request._runtime_user_message_id == 401 and request._runtime_user_message_index == 10 + + +def test_request_boundary_is_private_and_cannot_be_submitted(): + from consts.model import AgentRequest + request = AgentRequest(query="Current", _runtime_user_message_id=999, _runtime_user_message_index=99) + assert request._runtime_user_message_id is None and request._runtime_user_message_index is None + request._runtime_user_message_id = 401 + request._runtime_user_message_index = 10 + assert "_runtime_user_message_id" not in request.model_dump() + assert "_runtime_user_message_index" not in request.model_dump() diff --git a/test/backend/services/test_jiuwen_history_boundary.py b/test/backend/services/test_jiuwen_history_boundary.py new file mode 100644 index 0000000000..1945cda38c --- /dev/null +++ b/test/backend/services/test_jiuwen_history_boundary.py @@ -0,0 +1,23 @@ +"""An existing chat cannot fall back to client history when its DB boundary is unknown.""" + +from unittest.mock import AsyncMock + +import pytest +from consts.exceptions import ForbiddenError +from consts.model import AgentRequest +from management.services.agent import run as service + + +@pytest.mark.parametrize("boundary", [None, True, "12", 12]) +async def test_invalid_database_boundary_rejects_before_runtime_allocation(mocker, boundary): + mocker.patch.object(service, "get_current_run_user_message_id", return_value=boundary) + load = mocker.patch.object(service, "load_historical_context", return_value=None) + memory = mocker.patch.object(service, "build_memory_context") + allocate = mocker.patch.object(service, "create_agent_run_info", new=AsyncMock()) + request = AgentRequest(agent_id=42, conversation_id=101, query="Continue", + history=[{"role": "assistant", "content": "Client supplied history"}]) + with pytest.raises(ForbiddenError, match="history boundary"): + await service.prepare_agent_run(request, "owner", "tenant") + memory.assert_not_called() + allocate.assert_not_awaited() + assert load.call_count == (1 if boundary == 12 else 0) diff --git a/test/backend/services/test_model_runtime_provisioning_service.py b/test/backend/services/test_model_runtime_provisioning_service.py new file mode 100644 index 0000000000..8f348fd784 --- /dev/null +++ b/test/backend/services/test_model_runtime_provisioning_service.py @@ -0,0 +1,180 @@ +"""Management provisioning and request snapshot guards with external I/O isolated.""" + +import json +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from services.model_runtime_options_service import compile_model_runtime_profile +from services.model_runtime_provisioning_service import ( + build_runtime_outlet_snapshot, + model_source_fingerprint, +) +from services.model_runtime_snapshot_service import restore_runtime_model_snapshot + + +@pytest.fixture +def record(): + return {"model_id": 7, "tenant_id": "tenant", "model_type": "llm", "model_repo": "", + "model_name": "qwen3.5-plus", "model_factory": "alibaba-cn", "display_name": "outlet", + "base_url": "http://127.0.0.1:9999/v1", "api_key": "unit-secret", + "max_output_tokens": 512, "default_output_reserve_tokens": 256, + "temperature": .7, "top_p": None, "extra_params": {"enable_thinking": False}} + + +@pytest.fixture +def saved(record): + record["runtime_outlet_snapshot"] = build_runtime_outlet_snapshot(record) + assert "row" in record["runtime_outlet_snapshot"] + return record + + +def test_management_snapshot_uses_canonical_codec_without_credentials(saved): + profile = restore_runtime_model_snapshot(saved, "tenant") + assert profile.outlet_id == 7 and profile.identity.tenant_id == "tenant" + assert profile.parameter_values["max_output_tokens"] == 256 + assert profile.parameter_values["temperature"] == .7 + assert profile.capabilities.tools.status == "supported" + assert "unit-secret" not in json.dumps(saved["runtime_outlet_snapshot"]) + + +def test_unchanged_management_write_and_runtime_do_not_recompose(saved, mocker): + mocker.patch("services.model_profile_service.get_model_profile_catalog", side_effect=AssertionError) + assert build_runtime_outlet_snapshot(saved) == saved["runtime_outlet_snapshot"] + assert restore_runtime_model_snapshot(saved, "tenant").config_revision == 1 + + +def test_changed_settings_create_next_revision_without_mutating_prior_snapshot(saved, mocker): + before = deepcopy(saved) + mocker.patch("services.model_profile_service.get_model_profile_catalog", side_effect=AssertionError) + changed = {**saved, "temperature": .2} + changed["runtime_outlet_snapshot"] = build_runtime_outlet_snapshot(changed) + assert restore_runtime_model_snapshot(changed, "tenant").config_revision == 2 + assert restore_runtime_model_snapshot(saved, "tenant").parameter_values["temperature"] == .7 + assert saved == before + assert restore_runtime_model_snapshot(changed, "tenant").parameter_rules == restore_runtime_model_snapshot(saved, "tenant").parameter_rules + assert restore_runtime_model_snapshot(changed, "tenant").capabilities == restore_runtime_model_snapshot(saved, "tenant").capabilities + + +@pytest.mark.parametrize("field,value", [("base_url", "http://other/v1"), ("temperature", .1), ("api_key", "")]) +def test_unsynchronized_writes_fail_closed(saved, field, value): + saved[field] = value + with pytest.raises(ValueError, match="STALE_SNAPSHOT"): + restore_runtime_model_snapshot(saved, "tenant") + assert restore_runtime_model_snapshot(saved, "tenant", strict=False) is None + + +def test_connectivity_and_key_rotation_do_not_change_policy_fingerprint(saved): + assert model_source_fingerprint(saved) == model_source_fingerprint( + {**saved, "api_key": "rotated-secret", "connect_status": "available"}) + + +def test_cross_tenant_and_identity_tampering_rejected(saved): + with pytest.raises(ValueError, match="TENANT_MISMATCH"): + restore_runtime_model_snapshot(saved, "other") + saved["runtime_outlet_snapshot"]["row"]["model_id"] = 8 + with pytest.raises(ValueError, match="IDENTITY_MISMATCH"): + restore_runtime_model_snapshot(saved, "tenant") + + +def test_unknown_outlet_uses_text_and_bad_outlet_has_safe_diagnostic(record): + unknown = build_runtime_outlet_snapshot({**record, "model_name": "operator-custom", "model_factory": "custom"}) + assert unknown["row"]["native_tool_call_supported"] is False + failed = build_runtime_outlet_snapshot({**record, "base_url": "invalid"}) + assert failed["error"] == "MODEL_OUTLET_PROVISIONING_FAILED" + assert "unit-secret" not in json.dumps(failed) + + +def test_non_llm_has_no_runtime_profile(record): + assert build_runtime_outlet_snapshot({**record, "model_type": "embedding"}) is None + + +def test_compile_request_values_into_detached_saved_rules(saved): + profile = restore_runtime_model_snapshot(saved, "tenant") + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, temperature=.3, + enable_thinking=False, extra_body={"enable_thinking": False, "custom_flag": "value"}) + config = AgentConfig(name="test", description="test", model_name="outlet", tools=[]) + config.context_budget_snapshot = SimpleNamespace(requested_output_tokens=128) + effective = compile_model_runtime_profile(model, config) + assert effective.parameter_values["temperature"] == .3 + assert effective.parameter_values["max_output_tokens"] == 128 + assert effective.extra_api_parameters == [{"path": "/custom_flag", "value": "value"}] + assert profile.parameter_values["max_output_tokens"] == 256 + + +def test_compile_rejects_protected_extra_fields_and_missing_profile(saved): + profile = restore_runtime_model_snapshot(saved, "tenant") + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, enable_thinking=False, + extra_body={"authorization": "must-not-leak"}) + config = AgentConfig(name="test", description="test", model_name="outlet", tools=[]) + with pytest.raises(ValueError): + compile_model_runtime_profile(model, config) + + model.resolved_profile = None + with pytest.raises(ValueError, match="PROVISIONING_REQUIRED"): + compile_model_runtime_profile(model, config) + + +def test_effective_request_budget_takes_precedence_over_saved_agent_output(saved): + from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.concurrency import RunCancellationScope + + profile = restore_runtime_model_snapshot(saved, "tenant") + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, temperature=.7, + top_p=None, enable_thinking=False) + config = AgentConfig(name="test", description="test", model_name="outlet", tools=[], requested_output_tokens=256) + budget = SimpleNamespace(requested_output_tokens=128) + config.context_budget_snapshot = budget + model.resolved_profile = compile_model_runtime_profile(model, config) + info = SimpleNamespace(agent_config=config, context_budget_snapshot=budget, model_service=Mock(), model_deadline=None) + adapter = JiuwenAdapter(info) + adapter.scope = RunCancellationScope() + adapter.model_config = model + assert adapter._create_model().profile.parameter_values["max_output_tokens"] == 128 + + +def test_custom_output_cannot_override_resolved_budget(saved): + profile = restore_runtime_model_snapshot(saved, "tenant") + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, temperature=.7, top_p=None, + extra_body={"max_tokens": 99}, enable_thinking=False) + config = SimpleNamespace(context_budget_snapshot=SimpleNamespace(requested_output_tokens=128), + requested_output_tokens=None) + with pytest.raises(ValueError, match="LEGACY_PARAMETER_CONFLICT"): + compile_model_runtime_profile(model, config) + + +def test_corrupt_or_oversized_envelope_cannot_be_treated_as_ready(saved): + saved["runtime_outlet_snapshot"]["row"]["protocol_id"] = "unregistered" + assert restore_runtime_model_snapshot(saved, "tenant", strict=False) is None + repaired = build_runtime_outlet_snapshot(saved) + assert "row" in repaired and repaired["revision"] == 2 + saved["runtime_outlet_snapshot"] = repaired + saved["runtime_outlet_snapshot"]["padding"] = "x" * (1024 * 1024) + with pytest.raises(ValueError, match="STALE_SNAPSHOT"): + restore_runtime_model_snapshot(saved, "tenant") + + +def test_custom_values_use_saved_bindings_and_preserve_empty_objects(saved): + profile = restore_runtime_model_snapshot(saved, "tenant") + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, temperature=.7, top_p=None, + extra_body={"enable_thinking": True, "custom": {}}, enable_thinking=False) + effective = compile_model_runtime_profile(model, SimpleNamespace(context_budget_snapshot=None, + requested_output_tokens=None)) + assert effective.parameter_values["enable_thinking"] is True + assert effective.extra_api_parameters == [{"path": "/custom", "value": {}}] + + +def test_clearing_an_explicit_capacity_column_does_not_restore_a_catalog_default(record): + record["context_window_tokens"] = 300000 + record["runtime_outlet_snapshot"] = build_runtime_outlet_snapshot(record) + assert restore_runtime_model_snapshot(record, "tenant").capabilities.context.combined_window == 300000 + record["context_window_tokens"] = None + record["runtime_outlet_snapshot"] = build_runtime_outlet_snapshot(record) + assert restore_runtime_model_snapshot(record, "tenant").capabilities.context.combined_window is None diff --git a/test/backend/services/test_model_runtime_snapshot_service.py b/test/backend/services/test_model_runtime_snapshot_service.py new file mode 100644 index 0000000000..64903b2b9e --- /dev/null +++ b/test/backend/services/test_model_runtime_snapshot_service.py @@ -0,0 +1,34 @@ +"""Runtime outlet restoration boundaries without database or provider I/O.""" + +from unittest.mock import Mock + +import pytest +from services.model_runtime_snapshot_service import restore_runtime_model_snapshot + + +def test_unmigrated_record_remains_legacy(): + assert restore_runtime_model_snapshot({"model_id": 1}, "tenant") is None + + +def test_wrong_tenant_fails_before_decoding(mocker): + decoder = mocker.patch("nexent.core.model_service.profiles.outlet_storage.decode_resolved_model_profile") + with pytest.raises(ValueError, match="MODEL_OUTLET_TENANT_MISMATCH"): + restore_runtime_model_snapshot({"profile_schema_version": 1, "tenant_id": "other"}, "tenant") + decoder.assert_not_called() + + +def test_saved_record_uses_canonical_codec(mocker): + profile = Mock() + decoder = mocker.patch("nexent.core.model_service.profiles.outlet_storage.decode_resolved_model_profile", + return_value=profile) + record = {"profile_schema_version": 1, "tenant_id": "tenant"} + assert restore_runtime_model_snapshot(record, "tenant") is profile + assert decoder.call_args.args == (record,) + assert "registered_references" in decoder.call_args.kwargs + + +def test_corrupt_saved_record_does_not_fall_back(mocker): + mocker.patch("nexent.core.model_service.profiles.outlet_storage.decode_resolved_model_profile", + side_effect=ValueError("PROFILE_STORAGE_INVALID")) + with pytest.raises(ValueError, match="PROFILE_STORAGE_INVALID"): + restore_runtime_model_snapshot({"profile_schema_version": 1, "tenant_id": "tenant"}, "tenant") diff --git a/test/backend/utils/test_runtime_output.py b/test/backend/utils/test_runtime_output.py new file mode 100644 index 0000000000..376058ad65 --- /dev/null +++ b/test/backend/utils/test_runtime_output.py @@ -0,0 +1,42 @@ +"""Explicit native result and lossless envelope persistence unit contracts.""" + +import json +import pytest +from utils.runtime_output import RuntimeOutputState +from utils.agent_stream_utils import finalize_buffered_unit_fragments, serialize_stream_unit_content, is_stream_unit_continuation + + +def terminal(**kwargs): + return {"schema_version":1,"runtime_mode":"native_agent","agent_core":"jiuwen","type":"run_terminal", + "turn_id":"turn","sequence":3,"content":"", "status":"completed","end_reason":"runner_answer", + "final_content":"Final","final_block_ids":["block"],**kwargs} + + +def test_root_terminal_is_required_and_child_cannot_complete_parent(): + state=RuntimeOutputState(required=True) + state.observe(terminal(invocation_id="child")) + assert state.status=="failed" and not state.final_content + state.observe(terminal()) + assert state.status=="completed" and state.final_content=="Final" + state.observe(terminal(status="failed",final_content="",final_block_ids=[])) + assert state.status=="failed" and not state.final_content + + +@pytest.mark.parametrize("status",["failed","stopped","invalid"]) +def test_invalid_or_unsuccessful_terminal_never_confirms_later_content(status): + state=RuntimeOutputState() + state.observe(terminal(status=status)) + assert state.status=="failed" and not state.final_content + + +def test_fragment_join_preserves_native_envelope_and_block_boundaries(): + data={**terminal(),"type":"model_output","block_id":"a","content":"First "} + unit={"type":"model_output","content":"","unit_content":"","_runtime_envelope":data, + "_content_fragments":["First ","second"],"_block_id":"a"} + assert is_stream_unit_continuation(unit,True,"model_output",data) + assert not is_stream_unit_continuation(unit,True,"model_output",{**data,"block_id":"b"}) + finalize_buffered_unit_fragments([unit]) + restored=json.loads(unit["unit_content"]) + assert restored["content"]=="First second" and restored["block_id"]=="a" + assert restored["runtime_mode"]=="native_agent" + assert json.loads(serialize_stream_unit_content(terminal(),""))["final_block_ids"]==["block"] diff --git a/test/cases/d1/agent-core-factory.yaml b/test/cases/d1/agent-core-factory.yaml new file mode 100644 index 0000000000..edca2ddbce --- /dev/null +++ b/test/cases/d1/agent-core-factory.yaml @@ -0,0 +1,132 @@ +schema_version: '1.0' +stage: D1 +module: AGENT-CORE +cases: +- case_id: AGENT-CORE-D1-001 + feature_id: AGENT-CORE-001 + business_rule_ids: + - AGENT-CORE-001-R1 + title: Factory default and native dispatch + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: Factory default and native dispatch + preconditions: + - Use isolated run resources and deterministic local fixtures. + test_data: + core: jiuwen + legacy_core: smolagents + steps: + - order: 1 + action: 'Exercise scenario: Default selection is smolagents.' + expected: Default selection is smolagents + - order: 2 + action: 'Exercise scenario: Explicit jiuwen resolves a native adapter.' + expected: Explicit jiuwen resolves a native adapter + - order: 3 + action: 'Exercise scenario: Unknown and duplicate registrations fail.' + expected: Unknown and duplicate registrations fail + expected_results: &id001 + - Default selection is smolagents + - Explicit jiuwen resolves a native adapter + - Unknown and duplicate registrations fail + forbidden_side_effects: + - No smolagents execution in the Jiuwen branch. + - No unauthorized tools or cross-run resources. + execution: + profiles: + - name: mock + required_services: [] + timeout_seconds: 60 + unit_boundary: Agent core factory and adapter + inputs: + - Resolved Nexent agent and run configuration + assertions: *id001 +- case_id: AGENT-CORE-D1-002 + feature_id: AGENT-CORE-001 + business_rule_ids: + - AGENT-CORE-001-R2 + - AGENT-CORE-001-R3 + title: Native config and authorized tool translation + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: Native config and authorized tool translation + preconditions: + - Use isolated run resources and deterministic local fixtures. + test_data: + core: jiuwen + legacy_core: smolagents + steps: + - order: 1 + action: 'Exercise scenario: Model alias and maximum iterations are preserved.' + expected: Model alias and maximum iterations are preserved + - order: 2 + action: 'Exercise scenario: Business instructions exclude code-action templates.' + expected: Business instructions exclude code-action templates + - order: 3 + action: 'Exercise scenario: Unsupported configuration fails before provider calls.' + expected: Unsupported configuration fails before provider calls + - order: 4 + action: 'Exercise scenario: Only configured local and skill tools are native abilities.' + expected: Only configured local and skill tools are native abilities + expected_results: &id002 + - Model alias and maximum iterations are preserved + - Business instructions exclude code-action templates + - Unsupported configuration fails before provider calls + - Only configured local and skill tools are native abilities + forbidden_side_effects: + - No smolagents execution in the Jiuwen branch. + - No unauthorized tools or cross-run resources. + execution: + profiles: + - name: mock + required_services: [] + timeout_seconds: 60 + unit_boundary: Agent core factory and adapter + inputs: + - Resolved Nexent agent and run configuration + assertions: *id002 +- case_id: AGENT-CORE-D1-003 + feature_id: AGENT-CORE-001 + business_rule_ids: + - AGENT-CORE-001-R4 + title: Native stream output and terminal errors + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: Native stream output and terminal errors + preconditions: + - Use isolated run resources and deterministic local fixtures. + test_data: + core: jiuwen + legacy_core: smolagents + steps: + - order: 1 + action: 'Exercise scenario: Incremental output is preserved.' + expected: Incremental output is preserved + - order: 2 + action: 'Exercise scenario: Exactly one final answer is emitted.' + expected: Exactly one final answer is emitted + - order: 3 + action: 'Exercise scenario: Error and missing final result produce failure.' + expected: Error and missing final result produce failure + expected_results: &id003 + - Incremental output is preserved + - Exactly one final answer is emitted + - Error and missing final result produce failure + forbidden_side_effects: + - No smolagents execution in the Jiuwen branch. + - No unauthorized tools or cross-run resources. + execution: + profiles: + - name: mock + required_services: [] + timeout_seconds: 60 + unit_boundary: Agent core factory and adapter + inputs: + - Resolved Nexent agent and run configuration + assertions: *id003 diff --git a/test/cases/d2/agent-core-factory.yaml b/test/cases/d2/agent-core-factory.yaml new file mode 100644 index 0000000000..6d4cdeae6c --- /dev/null +++ b/test/cases/d2/agent-core-factory.yaml @@ -0,0 +1,49 @@ +schema_version: '1.0' +stage: D2 +module: AGENT-CORE +cases: +- case_id: AGENT-CORE-D2-001 + feature_id: AGENT-CORE-001 + business_rule_ids: + - AGENT-CORE-001-R1 + - AGENT-CORE-001-R5 + title: Core configuration protocol compatibility + type: CONTRACT + priority: P1 + status: active + automation: automated + objective: Core configuration protocol compatibility + preconditions: + - Use isolated run resources and deterministic local fixtures. + test_data: + core: jiuwen + legacy_core: smolagents + steps: + - order: 1 + action: 'Exercise scenario: Configuration request accepts jiuwen and rejects unknown + values.' + expected: Configuration request accepts jiuwen and rejects unknown values + - order: 2 + action: 'Exercise scenario: Partial update preserves omitted selection.' + expected: Partial update preserves omitted selection + - order: 3 + action: 'Exercise scenario: Export and version row preserve selection.' + expected: Export and version row preserve selection + expected_results: + - Configuration request accepts jiuwen and rejects unknown values + - Partial update preserves omitted selection + - Export and version row preserve selection + forbidden_side_effects: + - No smolagents execution in the Jiuwen branch. + - No unauthorized tools or cross-run resources. + execution: + profiles: + - name: mock + required_services: [] + timeout_seconds: 60 + contract: + protocol: Nexent Agent configuration JSON + request_expectations: + - Optional agent_core accepts smolagents or jiuwen. + response_expectations: + - Core choice is preserved; omitted choice remains compatible. diff --git a/test/cases/d3/agent-core-factory.yaml b/test/cases/d3/agent-core-factory.yaml new file mode 100644 index 0000000000..7c1ac13ab3 --- /dev/null +++ b/test/cases/d3/agent-core-factory.yaml @@ -0,0 +1,70 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +cases: +- case_id: AGENT-CORE-D3-001 + feature_id: AGENT-CORE-001 + business_rule_ids: + - AGENT-CORE-001-R2 + - AGENT-CORE-001-R3 + - AGENT-CORE-001-R4 + title: Nexent worker executes native model tools MCP and skills + type: AGENT-IT + priority: P1 + status: active + automation: automated + objective: Nexent worker executes native model tools MCP and skills + preconditions: + - Use isolated run resources and deterministic local fixtures. + test_data: + core: jiuwen + legacy_core: smolagents + steps: + - order: 1 + action: 'Exercise scenario: Native model receives local and authorized MCP tools.' + expected: Native model receives local and authorized MCP tools + - order: 2 + action: 'Exercise scenario: Local and MCP calls execute once with expected results.' + expected: Local and MCP calls execute once with expected results + - order: 3 + action: 'Exercise scenario: Authorized SKILL.md is loaded into native prompt.' + expected: Authorized SKILL.md is loaded into native prompt + - order: 4 + action: 'Exercise scenario: Worker emits one final answer and completed outcome.' + expected: Worker emits one final answer and completed outcome + - order: 5 + action: 'Exercise scenario: Native registrations and MCP session are released.' + expected: Native registrations and MCP session are released + - order: 6 + action: Replay prior user and assistant turns through direct SDK history and an authorized context snapshot. + expected: Native HTTP model receives system instructions first, each authorized historical message once and the current query once; snapshot input excludes fallback history. + - order: 7 + action: Ask a different follow-up question after the completed tool chain. + expected: Only the latest request executes; previous tool and MCP calls are not repeated. + - order: 8 + action: Inject one transient model failure after the local tool result and continue the native loop. + expected: The model call retries once and completes without replaying the successful local tool. + expected_results: &id001 + - Native model receives local and authorized MCP tools + - Local and MCP calls execute once with expected results + - Authorized SKILL.md is loaded into native prompt + - Worker emits one final answer and completed outcome + - Native registrations and MCP session are released + - Native HTTP model receives authorized history once without mixing message types or leaking fallback history. + - Task-changing follow-ups reuse prior context without repeating completed tools. + - One transient model failure recovers with one retry and no tool replay. + forbidden_side_effects: + - No smolagents execution in the Jiuwen branch. + - No unauthorized tools or cross-run resources. + execution: + profiles: + - name: mock + required_services: [] + timeout_seconds: 60 + integration_boundary: + - Nexent SDK worker + - Jiuwen Runner and native HTTP model/MCP fixtures + - Authorized skill snapshot + observations: *id001 + cleanup: + - Release native tool registrations, MCP client and run session. diff --git a/test/cases/d5/agent-core-factory.yaml b/test/cases/d5/agent-core-factory.yaml new file mode 100644 index 0000000000..6a3d9bcd10 --- /dev/null +++ b/test/cases/d5/agent-core-factory.yaml @@ -0,0 +1,58 @@ +schema_version: '1.0' +stage: D5 +module: AGENT-CORE +cases: +- case_id: AGENT-CORE-D5-001 + feature_id: AGENT-CORE-001 + business_rule_ids: + - AGENT-CORE-001-R3 + - AGENT-CORE-001-R4 + title: Cancellation timeout and isolation + type: RELIABILITY + priority: P1 + status: active + automation: automated + objective: Cancellation timeout and isolation + preconditions: + - Use isolated run resources and deterministic local fixtures. + test_data: + core: jiuwen + legacy_core: smolagents + steps: + - order: 1 + action: 'Exercise scenario: Blocked native task stops within close grace.' + expected: Blocked native task stops within close grace + - order: 2 + action: 'Exercise scenario: MCP tool deadline cancels call and closes client.' + expected: MCP tool deadline cancels call and closes client + - order: 3 + action: 'Exercise scenario: Concurrent owner registrations cannot cross runs.' + expected: Concurrent owner registrations cannot cross runs + - order: 4 + action: 'Exercise scenario: Cancelled worker emits no successful final answer.' + expected: Cancelled worker emits no successful final answer + expected_results: + - Blocked native task stops within close grace + - MCP tool deadline cancels call and closes client + - Concurrent owner registrations cannot cross runs + - Cancelled worker emits no successful final answer + forbidden_side_effects: + - No smolagents execution in the Jiuwen branch. + - No unauthorized tools or cross-run resources. + execution: + profiles: + - name: mock + required_services: [] + timeout_seconds: 60 + risk_type: RELIABILITY + risk: Stalled tasks leak resources or overwrite another run. + condition: + - Cancel a blocked model/tool task. + - Run isolated owners with identical tool names. + metrics: + - Termination latency and resource count + thresholds: + - Worker stops within 5 seconds. + - All owned resources return to baseline. + recovery_expectations: + - Subsequent independent run succeeds. diff --git a/test/changes/requirements/agent-core-factory.yaml b/test/changes/requirements/agent-core-factory.yaml new file mode 100644 index 0000000000..8dbb5eb396 --- /dev/null +++ b/test/changes/requirements/agent-core-factory.yaml @@ -0,0 +1,25 @@ +schema_version: '1.0' +changes: +- change_id: AGENT-CORE-REQ-001 + change_type: requirement + requirement_id: AGENT-CORE-REQ-001 + title: Add Jiuwen native agent core through a factory + description: Preserve Nexent configuration while translating native executable resources + and streaming results. + affected_features: + existing: [] + added: + - AGENT-CORE-001 + modified: [] + retired: [] + affected_cases: + existing: [] + added: + - AGENT-CORE-D1-001 + - AGENT-CORE-D1-002 + - AGENT-CORE-D1-003 + - AGENT-CORE-D2-001 + - AGENT-CORE-D3-001 + - AGENT-CORE-D5-001 + modified: [] + retired: [] diff --git a/test/features/agent-core-factory.yaml b/test/features/agent-core-factory.yaml new file mode 100644 index 0000000000..4341a2e901 --- /dev/null +++ b/test/features/agent-core-factory.yaml @@ -0,0 +1,33 @@ +schema_version: '1.0' +module: + id: AGENT-CORE + name: Agent core execution +features: +- feature_id: AGENT-CORE-001 + name: Optional Jiuwen native agent core + status: active + description: Execute Nexent configured agents through a factory and native core + adapter. + business_rules: + - rule_id: AGENT-CORE-001-R1 + description: Default smolagents and explicit jiuwen dispatch through a factory; + unknown cores fail closed. + - rule_id: AGENT-CORE-001-R2 + description: Jiuwen owns the model loop and authorized local, MCP and skill tool + execution. + - rule_id: AGENT-CORE-001-R3 + description: Unsupported policies fail before execution and unconfigured tools + are never exposed. + - rule_id: AGENT-CORE-001-R4 + description: Streaming has one final result; failures and cancellation preserve + typed outcomes and reclaim resources. + - rule_id: AGENT-CORE-001-R5 + description: Persisted core selection survives version snapshots and import/export; + old configs default to smolagents. + required_test_stages: + - D1 + - D2 + - D3 + - D5 + source: + spec_path: docs/Developing/runtime-agent-core-factory/proposal.md diff --git a/test/manifests/agent-core-factory.yaml b/test/manifests/agent-core-factory.yaml new file mode 100644 index 0000000000..61e3d639f1 --- /dev/null +++ b/test/manifests/agent-core-factory.yaml @@ -0,0 +1,68 @@ +schema_version: '1.0' +cases: +- case_id: AGENT-CORE-D1-001 + stage: D1 + status: implemented + contract_hash: sha256:dfbad2a7f97856e2eb424ad5f18e37f9c031ae5748062fb5553fe54e9b207170 + implementation_hash: sha256:e208ca0bce39e0eb17abde67711a4e95d354fa09e5ae9fe1da38ac6857be6602 + implementation: + - framework: pytest + file: test/automation/d1/test_agent_core_factory.py + selector: test_AGENT_CORE_D1_001_factory_dispatch_and_extensions + profiles: + - mock +- case_id: AGENT-CORE-D1-002 + stage: D1 + status: implemented + contract_hash: sha256:6badcde4e7c6f3d9617e9da687e03a0fb44a4047f4bba8c654dc46648c69c33f + implementation_hash: sha256:e208ca0bce39e0eb17abde67711a4e95d354fa09e5ae9fe1da38ac6857be6602 + implementation: + - framework: pytest + file: test/automation/d1/test_agent_core_factory.py + selector: test_AGENT_CORE_D1_002_native_config_and_fail_closed + profiles: + - mock +- case_id: AGENT-CORE-D1-003 + stage: D1 + status: implemented + contract_hash: sha256:c106f421ee0bba1054e2ff5c3d807ad6710caaba16b2ac2d4c9a709b198af163 + implementation_hash: sha256:e208ca0bce39e0eb17abde67711a4e95d354fa09e5ae9fe1da38ac6857be6602 + implementation: + - framework: pytest + file: test/automation/d1/test_agent_core_factory.py + selector: test_AGENT_CORE_D1_003_stream_and_terminal_errors + profiles: + - mock +- case_id: AGENT-CORE-D2-001 + stage: D2 + status: implemented + contract_hash: sha256:8c5b02fdcf6eb181dc0ae27ad8854c60319cc3a70467a64987067f04a4868933 + implementation_hash: sha256:52f8c50e3325e360a0f83cea2cf7d8ed2e710456395579c8c9e48eff4ac1e13f + implementation: + - framework: pytest + file: test/automation/d2/test_agent_core_factory_contract.py + selector: test_AGENT_CORE_D2_001_config_route_and_version + profiles: + - mock +- case_id: AGENT-CORE-D3-001 + stage: D3 + status: implemented + contract_hash: sha256:3828bf865ffc8f943aa7f5d171c653c47cffbac51daf9364520cf5a894c5f1b2 + implementation_hash: sha256:ac778b2d01a31c2e4b6e3781f5d6c4153dfa3283b0fb17cdecabb9f639e36f1c + implementation: + - framework: pytest + file: test/automation/d3/test_agent_core_factory_native.py + selector: test_AGENT_CORE_D3_001_native_worker + profiles: + - mock +- case_id: AGENT-CORE-D5-001 + stage: D5 + status: implemented + contract_hash: sha256:48c65f8c2decbee4d2965dae575b044dac258970e3b81353fc6ab7cd54a99ec7 + implementation_hash: sha256:d47350a8eaae3127f7bab09ab38013536580b911a5a656133fa5320924feddcc + implementation: + - framework: pytest + file: test/automation/d5/test_agent_core_factory_reliability.py + selector: test_AGENT_CORE_D5_001_cancellation_deadline_and_isolation + profiles: + - mock diff --git a/test/sdk/core/agents/test_core_child_dispatch.py b/test/sdk/core/agents/test_core_child_dispatch.py new file mode 100644 index 0000000000..487ba8d8d8 --- /dev/null +++ b/test/sdk/core/agents/test_core_child_dispatch.py @@ -0,0 +1,105 @@ +"""Child selection, snapshot isolation and adapter ownership contracts.""" + +from concurrent.futures import Future +from contextlib import nullcontext +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig +from nexent.core.agents.core_adapters.children import child_run_info, invoke_child +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.core_adapters.smolagents import SmolagentsAdapter +from nexent.core.concurrency import RunCancellationScope +from nexent.core.utils.observer import MessageObserver + + +def configuration(core="jiuwen"): + return AgentConfig(name="child", description="Child", model_name="shared", tools=[], agent_core=core) + + +@pytest.mark.parametrize("core", ["jiuwen", "smolagents"]) +def test_child_keeps_own_binding_and_has_no_parent_history_or_workspace_ownership(core): + child = configuration(core) + child.runtime_model_config = ModelConfig(cite_name="shared", model_name="child-model", url="http://child", + api_key="child-only", temperature=0.2, extra_body={"nested": {"a": 1}}) + parent = AgentRunInfo(query="parent", agent_config=configuration(), observer=MessageObserver(), stop_event=Event(), + model_config_list=[ModelConfig(cite_name="shared", model_name="parent-model", url="http://parent", + api_key="parent-only", temperature=0.9)], + history=[{"role": "user", "content": "parent-secret"}], workspace_run_id="parent-owned") + scope = RunCancellationScope(parent_stop_event=parent.stop_event) + info = child_run_info(parent, child, scope) + selected = info.model_config_list[0] + assert info.agent_config.agent_core == core + assert (selected.model_name, selected.url, selected.api_key, selected.temperature) == ( + "child-model", "http://child", "child-only", 0.2) + assert info.history is None and info.context_input is None and info.workspace_run_id is None + assert info.stop_event is scope.stop_event and info.observer is parent.observer + selected.extra_body["nested"]["a"] = 2 + assert child.runtime_model_config.extra_body["nested"]["a"] == 1 + assert parent.model_config_list[0].temperature == 0.9 + assert "runtime_model_config" not in child.model_dump() + + +@pytest.mark.parametrize("failure", [None, "validate", "prepare", "run"]) +def test_child_factory_selects_core_and_always_closes(mocker, failure): + info = Mock() + adapter = Mock() + adapter.run.return_value = "child-result" + if failure: + getattr(adapter, failure).side_effect = RuntimeError("controlled failure") + factory = mocker.patch("nexent.core.agents.core_adapters.children.AgentCoreFactory.create", return_value=adapter) + if failure: + with pytest.raises(RuntimeError, match="controlled failure"): + invoke_child(info, "child-task") + else: + assert invoke_child(info, "child-task") == "child-result" + factory.assert_called_once_with(info) + adapter.close.assert_called_once() + assert info.query == "child-task" + + +@pytest.mark.parametrize("task", ["", " ", None, 1]) +def test_empty_or_non_string_child_task_allocates_nothing(mocker, task): + factory = mocker.patch("nexent.core.agents.core_adapters.children.AgentCoreFactory.create") + with pytest.raises(ValueError): + invoke_child(Mock(), task) + factory.assert_not_called() + + +def test_smol_parent_accepts_child_with_different_core(): + parent = configuration("smolagents") + parent.worker_agents = [configuration("jiuwen")] + SmolagentsAdapter(Mock(agent_config=parent)).validate() + + +def test_child_binding_alias_mismatch_is_rejected(): + child = configuration() + child.runtime_model_config = ModelConfig(cite_name="other", model_name="child", url="http://child") + with pytest.raises(ValueError, match="alias mismatch"): + child_run_info(SimpleNamespace(model_config_list=[]), child, RunCancellationScope()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("lane", ["control-io", "model-tool-io"]) +async def test_only_collaboration_deadline_cancels_owner(lane, caplog): + future = Future() + if lane == "model-tool-io": + future.set_exception(TimeoutError("tool-local timeout")) + info = SimpleNamespace(collaboration_timeout_seconds=0.01, model_deadline=None, observer=Mock()) + adapter = JiuwenAdapter(info) + adapter.scope = RunCancellationScope() + adapter.manager = Mock() + adapter.manager.submit.return_value = SimpleNamespace(future=future, execution_id="owned") + adapter._observe_tool = Mock(return_value=nullcontext()) + with pytest.raises(TimeoutError): + await adapter._business_handler("delegate", lambda: None, lane=lane)() + assert adapter.scope.cancelled == (lane == "control-io") + if lane == "control-io": + assert future.cancelled() + adapter.manager.cancel.assert_called_once() + assert "event=agent_collaboration_timeout" in caplog.text + assert "phase=queue_or_execution" in caplog.text + else: + adapter.manager.cancel.assert_not_called() diff --git a/test/sdk/core/agents/test_jiuwen_capacity_output.py b/test/sdk/core/agents/test_jiuwen_capacity_output.py new file mode 100644 index 0000000000..f884f658e5 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_capacity_output.py @@ -0,0 +1,94 @@ +"""Shared CM capacity must survive Jiuwen observations without changing execution policy.""" + +import json +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agent_runtime_support.output import StepObservation +from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo +from nexent.core.agents.context import ContextManagerConfig +from nexent.core.agents.context.runtime import ManagedContextRuntime +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.core_adapters.jiuwen_context import JiuwenContextBridge +from nexent.core.concurrency import RunCancellationScope +from nexent.core.model_service.contracts import AssistantMessage, ModelError, TokenUsage +from nexent.core.utils.observer import MessageObserver +from pydantic import ValidationError + + +def make(mode, window=65536): + config = ContextManagerConfig(context_window_tokens=window, token_threshold=24000, + effective_input_limit_tokens=32000, compaction_trigger_threshold_tokens=24000, + policy_layers={"agent": {"processing_mode": mode}}) + info = AgentRunInfo(query="Current", observer=MessageObserver(), stop_event=Event(), model_config_list=[], + agent_config=AgentConfig(name="fixture", description="Fixture", model_name="fixture", agent_core="jiuwen", tools=[], + context_manager_config=config)) + adapter = JiuwenAdapter(info) + model = Mock() + model.profile.connection.timeout_seconds = 5 + bridge = JiuwenContextBridge(info, RunCancellationScope(), Mock(), model, []) + adapter.context_bridge = bridge + reference = ManagedContextRuntime(bridge.context_manager) + return adapter, info.observer, reference + + +@pytest.mark.parametrize("mode", ["passthrough", "adaptive_compact"]) +@pytest.mark.parametrize("failed", [False, True]) +def test_public_step_reuses_the_actual_nexent_cm_capacity(mode, failed): + adapter, observer, reference = make(mode) + adapter.events.begin_step(None) + if failed: + adapter.events.record_error(ModelError(category="MODEL_PROTOCOL_ERROR", scope="protocol", retryable=False, + safe_message="Model response failed", request_id="request")) + else: + adapter.events.model_event(SimpleNamespace(type="message_completed", message=AssistantMessage( + content_parts=[{"type": "text", "text": "Answer"}], finish_reason="stop", + usage=TokenUsage(input_tokens=24, output_tokens=3, total_tokens=27, origin="provider")))) + event = next(json.loads(e) for e in observer.get_cached_message() if json.loads(e)["type"] == "step_observation") + assert event["context_capacity"] == { + "context_window_tokens": reference.context_window_tokens, + "token_threshold": reference.token_threshold, + "effective_input_limit_tokens": reference.effective_input_limit_tokens, + "context_processing_mode": reference.processing_mode, + } + assert reference.processing_mode == mode + assert (event["step_error"] is not None) is failed + assert reference.context_manager.compression_calls_log == [] + + +def test_capacity_is_owned_by_each_run_not_cached_from_another_agent(): + first, first_observer, _ = make("passthrough", 65536) + second, second_observer, _ = make("passthrough", 131072) + for adapter in [first, second]: + adapter.events.begin_step(None) + adapter.events.model_event(SimpleNamespace(type="message_completed", message=AssistantMessage(finish_reason="stop"))) + values = [next(json.loads(e)["context_capacity"]["context_window_tokens"] for e in observer.get_cached_message() + if json.loads(e)["type"] == "step_observation") for observer in [first_observer, second_observer]] + assert values == [65536, 131072] + + +def test_legacy_observation_with_no_capacity_remains_unknown(): + observation = StepObservation(turn_id="turn", block_id="block", step_number=1, duration=0) + assert getattr(observation, "context_capacity", None) is None + assert observation.usage.input_tokens is None + + +@pytest.mark.parametrize("value", [-1, 0, True, "65536"]) +def test_public_capacity_rejects_invalid_or_coerced_values(value): + with pytest.raises(ValidationError): + StepObservation.model_validate({"turn_id": "turn", "block_id": "block", "step_number": 1, "duration": 0, + "context_capacity": {"context_window_tokens": value}}) + + +def test_unknown_window_and_unspecified_budget_remain_unknown_on_a_real_cm(): + adapter, observer, reference = make("passthrough", None) + reference.context_manager.config.effective_input_limit_tokens = 0 + adapter.events.begin_step(None) + adapter.events.model_event(SimpleNamespace(type="message_completed", message=AssistantMessage(finish_reason="stop"))) + observation = next(json.loads(e) for e in observer.get_cached_message() if json.loads(e)["type"] == "step_observation") + assert observation["context_capacity"]["context_window_tokens"] is None + assert observation["context_capacity"]["effective_input_limit_tokens"] is None + assert observation["context_capacity"]["token_threshold"] == 24000 + assert observation["usage"]["input_tokens"] is None diff --git a/test/sdk/core/agents/test_jiuwen_context.py b/test/sdk/core/agents/test_jiuwen_context.py new file mode 100644 index 0000000000..54ecf0f9bf --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_context.py @@ -0,0 +1,124 @@ +"""Shared CM consumption and native tool association regression contracts.""" + +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo +from nexent.core.agents.context import ContextItem, ContextItemInput, ContextManagerConfig +from nexent.core.agents.context.history_compression import HistorySummaryInput +from nexent.core.agents.context.rendering import ContextItemRenderer +from nexent.core.agents.core_adapters.jiuwen_context import JiuwenContextBridge, SummaryModel +from nexent.core.concurrency import RunCancellationScope +from nexent.core.concurrency.cancellation import RunTerminated +from nexent.core.utils.observer import MessageObserver + + +def make_bridge(mode="passthrough", items=(), sink=None): + config = ContextManagerConfig(effective_input_limit_tokens=1000, + compaction_trigger_threshold_tokens=100, compaction_target_tokens=80, + policy_layers={"agent": {"processing_mode": mode}}, history_summary_sink=sink) + info = AgentRunInfo(query="CURRENT_TASK", model_config_list=[], observer=MessageObserver(), stop_event=Event(), + agent_config=AgentConfig(name="fixture", description="Fixture", model_name="fixture", tools=[], + context_manager_config=config)) + scope = RunCancellationScope() + model = Mock(scope=scope) + model.profile.connection.timeout_seconds = 5 + return JiuwenContextBridge(info, scope, Mock(), model, items), info + + +def turn(): + return ContextItem.from_input(ContextItemInput(id="turn:1:2", type="conversation_turn", content={ + "user_message": "Historical task " * 100, "assistant_final_answer": "Completed answer " * 100, + "user_message_id": 1, "assistant_message_id": 2, + "attachments": [{"name": "file.txt", "url": "s3://files/old.txt", "ignored_secret": "HIDDEN"}], + "execution_records": [{"record_type": "error", "content": "Saved error", "tool_call_id": "old-call"}], + })) + + +def test_passthrough_uses_real_cm_and_keeps_current_call_ids_without_compaction(mocker): + bridge, info = make_bridge(items=[turn()]) + bridge.summary_model = Mock(side_effect=AssertionError("CM off must not summarize")) + forbidden = mocker.patch("smolagents.memory.AgentMemory", side_effect=AssertionError("Synthetic memory")) + messages = [{"role": "system", "content": "Policy"}, {"role": "user", "content": info.query}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "active-call", "type": "function", + "function": {"name": "allowed", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "active-call", "content": "Real observation"}] + result = bridge._assemble(messages, []) + assert str(result).count("CURRENT_TASK") == 1 + assert result[-1]["tool_call_id"] == "active-call" + assert result[-2]["tool_calls"][0]["id"] == "active-call" + assert "old-call" in str(result) and "Saved error" in str(result) and "HIDDEN" not in str(result) + assert "s3://files/old.txt" in str(result) + assert bridge.last_evidence.processing_mode == "passthrough" + assert not bridge.last_evidence.compression_attempted + forbidden.assert_not_called() + + +def test_saved_adaptive_mode_calls_shared_summary_and_persists_accepted_candidate(): + sink = Mock() + bridge, info = make_bridge("adaptive_compact", [turn()], sink) + headings = bridge.context_manager.config.summary_json_schema + markdown = "# Compact Result of History\n" + "\n".join( + "## " + key.replace("_", " ").title() + "\nPreserved fact" for key in headings) + bridge.summary_model = Mock(spec=["__call__"], return_value=SimpleNamespace(content=markdown)) + bridge._assemble([{"role": "user", "content": info.query}], []) + assert bridge.summary_model.called and sink.call_count == 1 + assert sink.call_args.args[0].covered_through_message_id == 2 + assert bridge.last_evidence.processing_mode == "adaptive_compact" + assert bridge.last_evidence.summary_persist_status == "succeeded" + assert info.agent_config.context_manager_config.history_summary_sink is sink + + +def test_invalid_summary_does_not_persist_checkpoint(): + sink = Mock() + bridge, info = make_bridge("adaptive_compact", [turn()], sink) + bridge.summary_model = Mock(spec=["__call__"], return_value=SimpleNamespace(content="Invalid summary")) + bridge._assemble([{"role": "user", "content": info.query}], []) + sink.assert_not_called() + assert bridge.last_evidence.new_summary_coverage is None + + +def test_historical_records_and_attachments_enter_summary_as_facts(): + rendered = ContextItemRenderer().render([turn()]) + source = HistorySummaryInput.from_items(None, [turn()]).render() + assert rendered[1]["role"] == "assistant" + for text in (str(rendered), source): + assert "s3://files/old.txt" in text and "old-call" in text and "Saved error" in text + assert "read_only" in text and "HIDDEN" not in text + + +def test_cancelled_context_does_not_assemble_or_call_model(): + bridge, info = make_bridge() + bridge.scope.cancel() + with pytest.raises(RunTerminated): + bridge._assemble([{"role": "user", "content": info.query}], []) + with pytest.raises(RunTerminated): + SummaryModel(Mock(scope=bridge.scope))([]) + + +@pytest.mark.asyncio +async def test_context_queue_deadline_cancels_before_work_can_start(): + from concurrent.futures import Future + + bridge, info = make_bridge() + bridge.context_timeout = 0.01 + future = Future() + bridge.manager.submit.return_value = SimpleNamespace(future=future, execution_id="queued-context") + with pytest.raises(TimeoutError): + await bridge.prepare_messages([{"role": "user", "content": info.query}], []) + assert future.cancelled() + assert bridge.scope.cancelled + assert bridge.last_evidence is None + bridge.manager.cancel.assert_called_once_with( + "queued-context", reason="Jiuwen context deadline exceeded", wait_timeout=0, mark_stuck_on_timeout=False) + + +@pytest.mark.asyncio +async def test_cancelled_context_is_rejected_before_worker_admission(): + bridge, info = make_bridge() + bridge.scope.cancel() + with pytest.raises(RunTerminated): + await bridge.prepare_messages([{"role": "user", "content": info.query}], []) + bridge.manager.submit.assert_not_called() diff --git a/test/sdk/core/agents/test_jiuwen_history.py b/test/sdk/core/agents/test_jiuwen_history.py new file mode 100644 index 0000000000..62587a360f --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_history.py @@ -0,0 +1,162 @@ +"""Regression coverage for history replay at the native model boundary.""" + +import sys +from contextlib import asynccontextmanager +from copy import deepcopy +from threading import Event +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.agent_model import ( + AgentConfig, + AgentHistory, + AgentRunInfo, + ModelConfig, +) +from nexent.core.agents.core_adapters.jiuwen import ( + JiuwenAdapter, + _prepend_context_messages, +) +from nexent.core.concurrency.cancellation import RunCancellationScope +from nexent.core.model_service.contracts import AssistantMessage as ServiceMessage +from nexent.core.model_service.contracts import ModelEvent +from nexent.core.utils.observer import MessageObserver +from openjiuwen.core.foundation.llm.model_clients.base_model_client import ( + BaseModelClient, +) +from openjiuwen.core.foundation.llm.schema.message import ( + AssistantMessage, + SystemMessage, + ToolMessage, + UserMessage, +) +from test_jiuwen_model_service import saved_profile as _saved_profile + +saved_profile = _saved_profile + + +def adapter_with_history(history, profile): + info = AgentRunInfo( + query="Current question", observer=MessageObserver(lang="en"), stop_event=Event(), + agent_config=AgentConfig(name="history", description="History fixture", model_name="fixture", tools=[]), + tenant_id="tenant", + model_config_list=[ModelConfig(cite_name="fixture", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile)], + history=history, + ) + adapter = JiuwenAdapter(info) + adapter.validate() + adapter.scope = RunCancellationScope(info.stop_event) + adapter._compile_context() + return adapter + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("dictionary_input", [False, True]) +async def test_history_reaches_native_model_in_order(monkeypatch, streaming, dictionary_input, saved_profile): + adapter = adapter_with_history([ + AgentHistory(role="user", content="Remember blue"), + AgentHistory(role="assistant", content="I remember blue"), + ], saved_profile) + captured = [] + + result = ServiceMessage(content_parts=[{"type": "text", "text": "answer"}], finish_reason="stop") + + def capture(request): + roles = {"application_instruction": "system", "user_input": "user", "assistant_history": "assistant"} + captured.extend({"role": roles[item.semantic_role], "content": item.content_parts[0]["text"]} + for item in request.messages) + + async def invoke(request, profile, **kwargs): + capture(request) + return result + + @asynccontextmanager + async def stream(request, profile, **kwargs): + capture(request) + async def events(): + yield ModelEvent(request_id=request.request_id, message_id=result.message_id, sequence=1, + type="message_completed", message=result, finish_reason="stop") + yield events() + + adapter.run_info.model_service = SimpleNamespace(ainvoke=invoke, astream=stream) + messages = [SystemMessage(content="Business instructions"), UserMessage(content="Current question")] + if dictionary_input: + messages = [message.model_dump() for message in messages] + model = adapter._create_model() + if streaming: + assert "".join([chunk.content async for chunk in model.stream(messages)]) == "answer" + else: + assert (await model.invoke(messages)).content == "answer" + assert [(message["role"], message["content"]) for message in captured] == [ + ("system", "Business instructions"), ("user", "Remember blue"), + ("assistant", "I remember blue"), ("user", "Current question"), + ] + assert len(messages) == 2 + + +async def test_empty_history_keeps_native_input(monkeypatch, saved_profile): + adapter = adapter_with_history([], saved_profile) + messages = [SystemMessage(content="Instructions"), UserMessage(content="New question")] + + async def invoke(request, profile, **kwargs): + assert [item.content_parts[0]["text"] for item in request.messages] == ["Instructions", "New question"] + raise RuntimeError("Provider unavailable") + + adapter.run_info.model_service = SimpleNamespace(ainvoke=invoke) + with pytest.raises(RuntimeError, match="Provider unavailable"): + await adapter._create_model().invoke(messages) + + +@pytest.mark.parametrize("dictionary_input", [False, True]) +def test_history_preserves_native_tool_calls_and_structured_content(dictionary_input): + assistant = AssistantMessage(content="", tool_calls=[ + {"id": "call-1", "type": "function", "function": {"name": "add", "arguments": '{"a": 2}'}} + ], reasoning_content="Tool reasoning") + tool = ToolMessage(content="2", tool_call_id="call-1") + prefix = [{"role": "user", "content": [{"type": "text", "text": "Earlier question"}]}] + messages = [SystemMessage(content="Instructions"), assistant, tool] + if dictionary_input: + messages = [message.model_dump() for message in messages] + original = deepcopy(messages) + actual = _prepend_context_messages(messages, prefix) + serialized = BaseModelClient._convert_messages_to_dict(actual) + assert serialized[1]["content"] == prefix[0]["content"] + assert serialized[2]["tool_calls"][0]["id"] == "call-1" + assert serialized[2]["reasoning_content"] == "Tool reasoning" + assert serialized[3]["tool_call_id"] == "call-1" + assert messages == original + if not dictionary_input: + assert actual[2] is assistant and actual[3] is tool + + +def test_native_prompt_and_retry_configuration(monkeypatch, saved_profile): + from nexent.core.agents.core_adapters import jiuwen + + adapter = adapter_with_history([AgentHistory(role="user", content="Analyze an old image")], saved_profile) + adapter.run_info.thread_manager = SimpleNamespace() + monkeypatch.setattr(adapter, "_skill_rails", list) + monkeypatch.setattr(jiuwen, "NexentAgent", lambda **kwargs: SimpleNamespace( + _prepare_file_workspace=lambda query: query, + )) + factory = Mock() + factory_module = ModuleType("openjiuwen.harness.factory") + factory_module.create_deep_agent = factory + monkeypatch.setitem(sys.modules, "openjiuwen.harness.factory", factory_module) + retry_rail = SimpleNamespace() + retry_factory = Mock(return_value=retry_rail) + retry_module = ModuleType("openjiuwen.harness.rails.model_anomaly_detection_rail") + retry_module.ModelAnomalyDetectionRail = retry_factory + monkeypatch.setitem(sys.modules, retry_module.__name__, retry_module) + adapter.prepare() + options = factory.call_args.kwargs + assert "only the latest user request" in options["system_prompt"] + assert "not pending tasks" in options["system_prompt"] + assert "Do not repeat earlier requests or tool calls" in options["system_prompt"] + assert options["enable_model_anomaly_detection_rail"] is False + assert options["enable_tool_resilience_rail"] is False + assert options["enable_skill_discovery"] is False + assert options["rails"] == [] + assert options["parallel_tool_calls"] is False + retry_factory.assert_not_called() diff --git a/test/sdk/core/agents/test_jiuwen_mcp_transport.py b/test/sdk/core/agents/test_jiuwen_mcp_transport.py new file mode 100644 index 0000000000..2053ac638c --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_mcp_transport.py @@ -0,0 +1,46 @@ +"""Unit regressions for native MCP transport configuration.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from openjiuwen.core.foundation.tool.mcp.base import McpServerConfig + +from nexent.core.agents.core_adapters.mcp import _ConfiguredNativeClient + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["streamable-http", "sse"]) +async def test_native_transport_preserves_headers_factory_and_session(monkeypatch, transport): + headers = {"Authorization": "fixture-token"} + http_client = MagicMock() + factory = MagicMock(return_value=http_client) + stream_context = MagicMock() + open_http = MagicMock(return_value=stream_context) + open_sse = MagicMock(return_value=stream_context) + session = MagicMock(initialize=AsyncMock()) + create_session = MagicMock(return_value=session) + monkeypatch.setattr("mcp.client.streamable_http.streamable_http_client", open_http) + monkeypatch.setattr("mcp.client.sse.sse_client", open_sse) + monkeypatch.setattr("mcp.ClientSession", create_session) + client = _ConfiguredNativeClient(McpServerConfig( + server_name="fixture", server_path="http://fixture/mcp", client_type=transport, auth_headers=headers, + ), factory) + streams = (MagicMock(), MagicMock(), MagicMock()) + client._exit_stack = MagicMock(enter_async_context=AsyncMock( + side_effect=[http_client, streams, session] if transport == "streamable-http" else [streams, session], + )) + assert await client.connect(timeout=3) + session.initialize.assert_awaited_once() + create_session.assert_called_once_with(streams[0], streams[1]) + if transport == "streamable-http": + factory.assert_called_once() + assert factory.call_args.kwargs["headers"] == headers + assert factory.call_args.kwargs["timeout"].connect == 3 + open_http.assert_called_once_with("http://fixture/mcp", http_client=http_client) + assert client._exit_stack.enter_async_context.call_args_list[0].args[0] is http_client + open_sse.assert_not_called() + else: + open_sse.assert_called_once_with("http://fixture/mcp", headers=headers, timeout=3, + httpx_client_factory=factory) + factory.assert_not_called() + open_http.assert_not_called() diff --git a/test/sdk/core/agents/test_jiuwen_model_service.py b/test/sdk/core/agents/test_jiuwen_model_service.py new file mode 100644 index 0000000000..99138ac927 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_model_service.py @@ -0,0 +1,464 @@ +"""Isolated mapping, completion, stream and lifecycle contracts for Jiuwen's model bridge.""" + +import uuid +from contextlib import asynccontextmanager +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from nexent.core.agents.core_adapters.jiuwen_messages import resource_view +from nexent.core.agents.core_adapters.jiuwen_model import JiuwenServiceModel +from nexent.core.agents.core_adapters.jiuwen_protocol import JiuwenProtocolError +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.contracts import ( + AssistantMessage, + ModelEvent, + TokenUsage, + ToolCall, +) +from nexent.core.model_service.profiles.composition import compose_provider_model_policy +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_resolution import ( + construct_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, +) +from nexent.core.model_service.profiles.resolver import load_profile_catalog +from nexent.core.model_service.registry import model_service_references +from nexent.core.model_service.service import ModelServiceError +from nexent.core.utils.observer import MessageObserver + + +@pytest.fixture +def saved_profile(): + registry = dict(model_service_references()) + catalog = load_profile_catalog() + policy = compose_provider_model_policy( + catalog, provider_key="alibaba-cn", request_model_id="qwen3.5-plus", model_profile_key=None, + base_bindings={key: registry[key][0].model_dump() for key in + ("protocol", "auth", "adapter", "code_converter", "native_converter")}, + base_instruction=VersionedReference(ref="system_only_v1", version=1), registered_references=registry, + submitted_values={"enable_thinking": False}, + ) + profile = construct_resolved_model_profile( + policy, catalog, tenant_id="tenant", display_name="fixture", model_type="llm", provider_id="alibaba", + connection=OutletConnection(base_url="http://fixture/v1", ssl_verify=True, timeout_seconds=5, + concurrency_limit=None, credential_ref={"kind": "model_record", "model_id": None}), + connection_rules=ConnectionRules(credential_kind="model_record", relative_path="/chat/completions", + method="POST", headers={}), + context_configuration=ContextConfiguration(output_reserve_tokens=None, tokenizer_family=None, + capacity_source="profile", capability_profile_version=None), + registered_references=registry, + ) + data = profile.model_dump() + data["outlet_id"] = 7 + data["connection"]["credential_ref"]["model_id"] = 7 + return type(profile).model_validate(data) + + +MESSAGES = [{"role": "system", "content": "Instructions"}, {"role": "user", "content": "Latest request"}] +TOOLS = [{"name": "add", "description": "Add", "parameters": {"type": "object", "properties": { + "a": {"type": "integer"}, "b": {"type": "integer"}}, "required": ["a", "b"]}}] + + +def message(content="Answer", **kwargs): + return AssistantMessage(content_parts=[{"type": "text", "text": content}], finish_reason="stop", **kwargs) + + +def stream_service(build_events): + closed = Mock() + + @asynccontextmanager + async def astream(request, profile, **kwargs): + async def events(): + for event in build_events(request): + yield event + try: + yield events() + finally: + closed() + + return SimpleNamespace(astream=astream), closed + + +def event(request, kind, seq=1, **kwargs): + return ModelEvent(request_id=request.request_id, message_id=kwargs.pop("message_id", uuid.uuid4()), + sequence=seq, type=kind, **kwargs) + + +def completion(request, result, seq=2): + return event(request, "message_completed", seq, message_id=result.message_id, + message=result, finish_reason=result.finish_reason) + + +async def test_invoke_uses_detached_snapshot_and_authorized_history(saved_profile): + service = SimpleNamespace(ainvoke=AsyncMock(return_value=message())) + prefix = [{"role": "user", "content": "Earlier"}, {"role": "assistant", "content": "Previous answer"}] + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope(), prefix=prefix) + original = deepcopy(MESSAGES) + saved_profile.parameter_values["temperature"] = 0.123 + prefix[0]["content"] = "mutated" + response = await model.invoke(MESSAGES, tools=TOOLS) + request, snapshot = service.ainvoke.await_args.args + assert [part["text"] for item in request.messages for part in item.content_parts] == [ + "Instructions", "Earlier", "Previous answer", "Latest request", + ] + assert snapshot.parameter_values.get("temperature") != 0.123 + assert MESSAGES == original + assert response.content == "Answer" and response.usage_metadata is None + assert model.last_usage.origin == "unknown" + + +@pytest.mark.parametrize("kwargs", [{"temperature": 0.1}, {"max_tokens": 1}, {"model": "other"}]) +async def test_runtime_generation_or_model_override_rejected(saved_profile, kwargs): + service = SimpleNamespace(ainvoke=AsyncMock()) + with pytest.raises(JiuwenProtocolError): + await JiuwenServiceModel(service, saved_profile, RunCancellationScope()).invoke(MESSAGES, **kwargs) + service.ainvoke.assert_not_awaited() + + +@pytest.mark.parametrize("result,category", [ + (AssistantMessage(finish_reason="mystery"), "MODEL_FINISH_REASON_UNSUPPORTED"), + (AssistantMessage(finish_reason="length"), "MODEL_RESPONSE_INCOMPLETE"), + (AssistantMessage(finish_reason="stop"), "MODEL_EMPTY_RESPONSE"), + (message(refusal="refused"), "MODEL_RESPONSE_REFUSED"), + (AssistantMessage(finish_reason="tool_calls"), "JIUWEN_TOOL_FINISH_MISMATCH"), +]) +async def test_noncomplete_response_is_not_success(saved_profile, result, category): + service = SimpleNamespace(ainvoke=AsyncMock(return_value=result)) + with pytest.raises(ModelServiceError) as failure: + await JiuwenServiceModel(service, saved_profile, RunCancellationScope()).invoke(MESSAGES) + assert failure.value.error.category == category + + +@pytest.mark.parametrize("name,args,error", [ + ("unknown", {}, "JIUWEN_UNAUTHORIZED_TOOL"), ("add", {"a": "x", "b": 3}, "INVALID_TOOL_ARGUMENTS"), + ("add", {"a": 2}, "INVALID_TOOL_ARGUMENTS"), +]) +async def test_complete_calls_validated_before_exposure(saved_profile, name, args, error): + import json + result = AssistantMessage(finish_reason="tool_calls", tool_calls=[ToolCall( + provider_call_id="provider-1", index=0, name=name, raw_arguments=json.dumps(args), parsed_arguments=args, + )]) + service = SimpleNamespace(ainvoke=AsyncMock(return_value=result)) + with pytest.raises(ModelServiceError) as failure: + await JiuwenServiceModel(service, saved_profile, RunCancellationScope()).invoke(MESSAGES, tools=TOOLS) + assert failure.value.error.category == error + + +async def test_streams_visible_deltas_once_and_closes(saved_profile): + result = message("Answer", usage=TokenUsage(input_tokens=2, output_tokens=1, total_tokens=3, origin="provider")) + service, closed = stream_service(lambda request: [event(request, "text_delta", delta="Answer"), + completion(request, result)]) + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope()) + chunks = [chunk async for chunk in model.stream(MESSAGES)] + assert "".join(chunk.content for chunk in chunks) == "Answer" + assert chunks[-1].finish_reason == "stop" + assert chunks[-1].usage_metadata.total_tokens == 3 + closed.assert_called_once() + + +@pytest.mark.parametrize("variant", ["missing", "duplicate", "mismatch"]) +async def test_stream_completion_contract(saved_profile, variant): + result = message() + def events(request): + output = [event(request, "text_delta", delta="partial" if variant == "mismatch" else "Answer")] + if variant != "missing": + output.append(completion(request, result)) + if variant == "duplicate": + output.append(completion(request, result, 3)) + return output + service, closed = stream_service(events) + with pytest.raises(ModelServiceError): + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope()) + _ = [chunk async for chunk in model.stream(MESSAGES)] + closed.assert_called_once() + + +async def test_consumer_close_releases_stream(saved_profile): + service, closed = stream_service(lambda request: [event(request, "text_delta", delta="Answer"), + completion(request, message())]) + stream = JiuwenServiceModel(service, saved_profile, RunCancellationScope()).stream(MESSAGES) + assert (await anext(stream)).content == "Answer" + await stream.aclose() + closed.assert_called_once() + + +async def test_pre_cancelled_call_never_sends(saved_profile): + scope = RunCancellationScope() + scope.cancel() + service = SimpleNamespace(ainvoke=AsyncMock()) + with pytest.raises(RunTerminated): + await JiuwenServiceModel(service, saved_profile, scope).invoke(MESSAGES) + service.ainvoke.assert_not_awaited() + + +def test_tool_history_and_input_are_detached(): + messages = [*MESSAGES, {"role": "assistant", "content": "", "tool_calls": [{ + "id": "provider-1", "function": {"name": "add", "arguments": '{"a":2,"b":3}'}}]}, + {"role": "tool", "content": "5", "tool_call_id": "provider-1"}] + original = deepcopy(messages) + view = resource_view(messages, TOOLS) + assert view.history[-1].message.tool_call_id == "provider-1" + assert view.history[-2].message.tool_calls[0].provider_call_id == "provider-1" + assert messages == original + + +def test_mixed_text_media_input_keeps_parts_and_order(): + content = ["Question", {"type": "image_url", "image_url": {"url": "https://fixture.invalid/image.png"}}] + view = resource_view([{"role": "user", "content": content}], []) + assert view.current_user_input.message.content_parts == [ + {"type": "text", "text": "Question"}, content[1], + ] + content[1]["image_url"]["url"] = "changed" + assert view.current_user_input.message.content_parts[1]["image_url"]["url"] != "changed" + + +async def test_text_mode_builds_internal_call_without_provider_tools(saved_profile): + data = saved_profile.model_dump() + data["capabilities"]["tools"]["status"] = "unknown" + profile = type(saved_profile).model_validate(data) + service = SimpleNamespace(ainvoke=AsyncMock(return_value=message("add(a=2,b=3)"))) + model = JiuwenServiceModel(service, profile, RunCancellationScope()) + response = await model.invoke(MESSAGES, tools=TOOLS) + assert response.tool_calls[0].name == "nexent_execute_block" + request = service.ainvoke.await_args.args[0] + assert request.tools == [] + assert "available_tools" in str(request.model_dump()) + assert "controlled text execution protocol" in str(request.model_dump()) + + +async def test_tool_deltas_cannot_be_executed(saved_profile): + from nexent.core.model_service.contracts import ToolCallDelta + + result = AssistantMessage(finish_reason="tool_calls", tool_calls=[ToolCall( + provider_call_id="provider-1", index=0, name="add", raw_arguments='{"a":2,"b":3}', + parsed_arguments={"a": 2, "b": 3}, + )]) + service, closed = stream_service(lambda request: [ + event(request, "tool_call_delta", tool_call_delta=ToolCallDelta( + local_call_id=uuid.uuid4(), index=0, provider_call_id="provider-1", arguments_delta='{"a":', + )), completion(request, result), + ]) + chunks = [chunk async for chunk in JiuwenServiceModel(service, saved_profile, RunCancellationScope()).stream( + MESSAGES, tools=TOOLS, + )] + assert len(chunks) == 1 + assert chunks[0].tool_calls[0].id == "provider-1" + assert chunks[0].tool_calls[0].arguments == '{"a":2,"b":3}' + closed.assert_called_once() + + +async def test_mid_stream_cancellation_closes(saved_profile): + scope = RunCancellationScope() + + def events(request): + yield event(request, "text_delta", delta="partial") + scope.cancel() + yield completion(request, message()) + + service, closed = stream_service(events) + public = [] + model = JiuwenServiceModel(service, saved_profile, scope, on_event=public.append) + with pytest.raises(RunTerminated): + _ = [chunk async for chunk in model.stream(MESSAGES)] + assert any(e.type == "text_delta" and e.delta == "partial" for e in public) + closed.assert_called_once() + + +@pytest.mark.parametrize("variant", ["missing", "tenant", "endpoint", "unsaved"]) +def test_adapter_requires_trusted_matching_snapshot(saved_profile, variant): + from threading import Event + + from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig + from nexent.core.agents.core_adapters.base import UnsupportedAgentCoreConfig + from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + data = saved_profile.model_dump() + if variant == "unsaved": + data["outlet_id"] = data["connection"]["credential_ref"]["model_id"] = None + model = ModelConfig(cite_name="fixture", model_name="qwen3.5-plus", url="http://fixture/v1", + resolved_profile=None if variant == "missing" else type(saved_profile).model_validate(data)) + if variant == "endpoint": + model.url = "http://other/v1" + info = AgentRunInfo(query="Request", observer=Mock(spec=MessageObserver), stop_event=Event(), + tenant_id="other" if variant == "tenant" else "tenant", model_config_list=[model], + agent_config=AgentConfig(name="fixture", description="Fixture", model_name="fixture", tools=[])) + with pytest.raises(UnsupportedAgentCoreConfig): + JiuwenAdapter(info).validate() + + +def test_request_output_budget_cannot_be_silently_ignored(saved_profile): + from nexent.core.agents.core_adapters.base import UnsupportedAgentCoreConfig + from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + + cap = saved_profile.parameter_values.get("max_output_tokens", saved_profile.parameter_values.get("max_tokens")) + info = SimpleNamespace(agent_config=SimpleNamespace(requested_output_tokens=None), + context_budget_snapshot=SimpleNamespace(requested_output_tokens=(cap or 0) + 1)) + adapter = JiuwenAdapter(info) + adapter.model_config = SimpleNamespace(resolved_profile=saved_profile) + with pytest.raises(UnsupportedAgentCoreConfig, match="output budget"): + adapter._create_model() + + +@pytest.mark.parametrize("invalid", [None, "tools", "refusal", "nontext"]) +def test_context_summary_accepts_shared_chat_messages_and_validates_completion(saved_profile, invalid): + from nexent.core.agents.core_adapters.jiuwen_context import SummaryModel + from smolagents.models import ChatMessage, MessageRole + + service = Mock() + response = message("Summary") + if invalid == "tools": + response = message("Summary", tool_calls=[ToolCall(provider_call_id="call", index=0, + name="unexpected", raw_arguments="{}")]) + elif invalid == "refusal": + response = message("Summary", refusal="Cannot summarize") + elif invalid == "nontext": + response = SimpleNamespace(finish_reason="stop", tool_calls=[], refusal=None, + content_parts=[{"type": "image", "url": "fixture"}]) + service.invoke.return_value = response + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope()) + summary = SummaryModel(model) + messages = [ChatMessage(role=MessageRole.SYSTEM, content=[{"type":"text", "text":"Summary policy"}]), + ChatMessage(role=MessageRole.USER, content=[{"type":"text", "text":"Completed fact"}])] + original = saved_profile.model_dump() + if invalid: + with pytest.raises(ValueError): + summary(messages, max_tokens=1000) + else: + assert summary(messages, max_tokens=1000).content == "Summary" + request, profile = service.invoke.call_args.args + assert profile.parameter_values["max_output_tokens"] == 1000 + assert profile.parameter_values["enable_thinking"] is False + assert not request.tools + assert request.messages[-1].content_parts == [{"type": "text", "text": "Completed fact"}] + assert saved_profile.model_dump() == original + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("kind", ["arguments", "empty", "reasoning", "length"]) +async def test_logical_recovery_preserves_request_and_separates_counters(saved_profile, streaming, kind): + values = {"content_parts": [{"type": "text", "text": "Partial" if kind == "length" else ""}], + "finish_reason": "length" if kind == "length" else "stop"} + if kind == "reasoning": + values["reasoning_parts"] = [{"type": "text", "text": "Thinking"}] + if kind == "arguments": + values.update(finish_reason="tool_calls", tool_calls=[ToolCall(provider_call_id="bad", index=0, + name="add", raw_arguments='{"a":1}', parsed_arguments={"a": 1})]) + bad = AssistantMessage(**values) + requests, corrections, errors = [], [], [] + responses = [bad, message("Recovered")] + async def invoke(request, *_args, **_kwargs): + requests.append(request) + return responses.pop(0) + @asynccontextmanager + async def stream(request, *_args, **_kwargs): + async def events(): + yield completion(request, await invoke(request)) + yield events() + model = JiuwenServiceModel(SimpleNamespace(ainvoke=invoke, astream=stream), saved_profile, + RunCancellationScope(), on_recovery=corrections.append, on_error=errors.append) + original = deepcopy(MESSAGES) + if streaming: + result = [chunk async for chunk in model.stream(MESSAGES, tools=TOOLS)] + assert result[-1].content == "Recovered" + else: + assert (await model.invoke(MESSAGES, tools=TOOLS)).content == "Recovered" + assert MESSAGES == original and len(requests) == 2 + assert requests[0].request_id != requests[1].request_id + assert model.logical_requests == 2 and len(corrections) == len(errors) == 1 + assert model.argument_corrections == (kind == "arguments") + assert model.response_corrections == (kind != "arguments") + assert "Regenerate this model step" in requests[1].model_dump_json() + + +async def test_argument_step_and_turn_budget_are_independent(saved_profile): + bad = AssistantMessage(finish_reason="tool_calls", tool_calls=[ToolCall( + provider_call_id="bad", index=0, name="add", raw_arguments="{}", parsed_arguments={})]) + service = SimpleNamespace(ainvoke=AsyncMock(side_effect=[bad, message(), bad, message(), bad, message(), bad])) + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope()) + for _ in range(3): + await model.invoke(MESSAGES, tools=TOOLS) + with pytest.raises(ModelServiceError, match="INVALID_TOOL_ARGUMENTS"): + await model.invoke(MESSAGES, tools=TOOLS) + assert model.argument_corrections == 3 and service.ainvoke.await_count == 7 + service.ainvoke = AsyncMock(return_value=bad) + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope()) + with pytest.raises(ModelServiceError): + await model.invoke(MESSAGES, tools=TOOLS) + assert service.ainvoke.await_count == 2 and model.argument_corrections == 1 + + +@pytest.mark.parametrize("stop", ["cancel", "deadline"]) +async def test_no_followup_after_correction_cancel_or_deadline(saved_profile, stop): + import time + scope = RunCancellationScope() + service = SimpleNamespace(ainvoke=AsyncMock(return_value=message(""))) + model = JiuwenServiceModel(service, saved_profile, scope) + def terminate(_details): + if stop == "cancel": + scope.cancel() + else: + model.deadline = time.monotonic() - 1 + model.on_recovery = terminate + with pytest.raises((RunTerminated, ModelServiceError)): + await model.invoke(MESSAGES) + assert service.ainvoke.await_count == 1 + + +async def test_rejected_stream_partial_is_not_native_history(saved_profile): + requests, public = [], [] + @asynccontextmanager + async def stream(request, *_args, **_kwargs): + requests.append(request) + first = len(requests) == 1 + result = (AssistantMessage(content_parts=[{"type": "text", "text": "PARTIAL"}], finish_reason="length") + if first else message("Recovered")) + async def events(): + yield event(request, "text_delta", delta="PARTIAL" if first else "Recovered") + yield completion(request, result) + yield events() + model = JiuwenServiceModel(SimpleNamespace(astream=stream), saved_profile, + RunCancellationScope(), on_event=public.append) + chunks = [chunk async for chunk in model.stream(MESSAGES)] + assert "".join(c.content or "" for c in chunks) == "Recovered" + assert [e.delta for e in public if e.type == "text_delta"] == ["PARTIAL", "Recovered"] + + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("bad", ["print(1)", "a=", ""]) +async def test_text_code_arguments_use_the_shared_correction_quota(saved_profile, streaming, bad): + data = saved_profile.model_dump() + data["capabilities"]["tools"]["status"] = "unsupported" + profile = type(saved_profile).model_validate(data) + responses = [message(bad), message("add(a=2,b=3)")] + requests = [] + async def invoke(request, *_args, **_kwargs): + requests.append(request) + return responses.pop(0) + @asynccontextmanager + async def stream(request, *_args, **_kwargs): + async def events(): + yield completion(request, await invoke(request)) + yield events() + model = JiuwenServiceModel(SimpleNamespace(ainvoke=invoke, astream=stream), profile, RunCancellationScope()) + if streaming: + result = [c async for c in model.stream(MESSAGES, tools=TOOLS)] + assert result[-1].tool_calls[0].name == "nexent_execute_block" + else: + assert (await model.invoke(MESSAGES, tools=TOOLS)).tool_calls[0].name == "nexent_execute_block" + assert len(requests) == 2 and model.argument_corrections == 1 and model.response_corrections == 0 + + +async def test_persistent_bad_code_stops_after_one_correction(saved_profile): + data = saved_profile.model_dump() + data["capabilities"]["tools"]["status"] = "unsupported" + service = SimpleNamespace(ainvoke=AsyncMock(return_value=message("x=1"))) + model = JiuwenServiceModel(service, type(saved_profile).model_validate(data), RunCancellationScope()) + with pytest.raises(ModelServiceError, match="JIUWEN_NESTED_EXECUTION_BLOCK"): + await model.invoke(MESSAGES) + assert service.ainvoke.await_count == 2 and model.argument_corrections == 1 diff --git a/test/sdk/core/agents/test_jiuwen_output.py b/test/sdk/core/agents/test_jiuwen_output.py new file mode 100644 index 0000000000..1cbd336ef6 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_output.py @@ -0,0 +1,128 @@ +"""Public output ordering, explicit finality, call correlation and error contracts.""" + +import json +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError +from nexent.core.agent_runtime_support.output import RunResult, StepObservation +from nexent.core.agents.core_adapters.events import JiuwenEventAdapter +from nexent.core.model_service.contracts import AssistantMessage, TokenUsage, ToolCall +from nexent.core.utils.observer import MessageObserver + + +def make(): + observer=MessageObserver() + return JiuwenEventAdapter(observer,"agent","turn"),observer + + +def event(kind,**kwargs): + return SimpleNamespace(type=kind,**kwargs) + + +def output(observer): + return [json.loads(value) for value in observer.get_cached_message()] + + +def test_ordered_content_and_reasoning_are_not_reclassified_or_duplicated(): + bridge,observer=make() + bridge.begin_step(None) + bridge.model_event(event("text_delta",delta="First ")) + bridge.model_event(event("reasoning_delta",delta="Explicit reasoning")) + bridge.model_event(event("text_delta",delta="body")) + bridge.model_event(event("message_completed",message=AssistantMessage( + content_parts=[{"type":"text","text":"First body"}], + reasoning_parts=[{"type":"text","text":"Explicit reasoning"}],finish_reason="stop"))) + bridge.emit({"type":"answer","payload":{"output":"First body"}}) + result=bridge.finish() + events=output(observer) + native=[e for e in events if "sequence" in e] + assert [e["type"] for e in native]==["step_count","model_output","model_output_deep_thinking","model_output","step_observation","run_terminal"] + assert [e["sequence"] for e in native]==list(range(1,7)) + assert result.status=="completed" and result.final_block_ids==[native[1]["block_id"]] + assert sum(e["type"]=="final_answer" for e in events)==1 + assert native[-2]["usage"]["origin"]=="unknown" and native[-2]["usage"]["input_tokens"] is None + assert bridge.finish() is result + with pytest.raises(RuntimeError,match="after terminal"): + bridge.output(type("Kind",(),{"value":"model_output"})(),"late") + + +def test_tool_pair_preserves_full_provider_id_and_invocation_identity(): + bridge,observer=make() + provider_id="provider-"+"a"*300 + bridge.calls=[ToolCall(provider_call_id=provider_id,index=0,name="tool",raw_arguments='{"a":1}',parsed_arguments={"a":1})] + with observer.tool_call_context("local"): + found=bridge.tool_call("tool",{"a":1},"local") + bridge.tool_result("local",found,"Exact error",True) + call,result=output(observer) + assert call["provider_call_id"]==result["provider_call_id"]==provider_id + assert call["local_call_id"]==result["local_call_id"]=="local" + assert result["result"]=="Exact error" and result["is_error"] + + +@pytest.mark.parametrize("status",["failed","stopped"]) +def test_unsuccessful_results_cannot_confirm_content(status): + with pytest.raises(ValidationError):RunResult(turn_id="turn",status=status,end_reason="error",final_content="False success") + bridge,observer=make() + bridge.begin_step(None) + bridge.model_event(event("text_delta",delta="Visible partial")) + result=bridge.finish(status) + assert result.status==status and result.final_content=="" and not result.final_block_ids + events=output(observer) + assert events[1]["content"]=="Visible partial" and events[-1]["type"]=="run_terminal" + assert not any(e["type"]=="final_answer" for e in events) + + +def test_missing_and_duplicate_runner_terminal_are_contract_errors(): + bridge,observer=make() + assert bridge.finish().end_reason=="JIUWEN_TERMINAL_MISSING" + bridge,observer=make() + bridge.emit({"type":"answer","payload":{"output":"Final"}}) + with pytest.raises(RuntimeError,match="multiple terminal"): + bridge.emit({"type":"answer","payload":{"output":"Later"}}) + assert bridge.finish().status=="failed" + assert not any(e["type"]=="final_answer" for e in output(observer)) + + +def test_native_llm_output_does_not_duplicate_canonical_model_events(): + bridge,observer=make() + bridge.emit({"type":"llm_output","payload":{"content":"Duplicate","reasoning_content":"Duplicate"}}) + assert output(observer)==[] + observation=StepObservation(turn_id="turn",block_id="block",step_number=1,duration=0,usage=TokenUsage()) + assert observation.usage.total_tokens is None + + +@pytest.mark.parametrize("result", [{"success":False,"data":{"result":"MCP error"}}, + '{"status":"error","message":"Skill error"}']) +def test_explicit_resource_failure_status_survives_later_text(result): + from threading import Event + from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo + from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + + observer=MessageObserver() + info=AgentRunInfo(query="Current",observer=observer,stop_event=Event(),model_config_list=[], + agent_config=AgentConfig(name="agent",description="Fixture",model_name="fixture",tools=[])) + adapter=JiuwenAdapter(info) + with adapter._observe_tool("tool",{}):adapter._resource_result(result) + events=output(observer) + saved=next(e for e in events if e["type"]=="tool_result") + assert saved["is_error"] is True + assert "error" in saved["result"] + adapter.events.emit({"type":"answer","payload":{"output":"Continued"}}) + assert adapter.events.finish().status=="completed" + assert saved["is_error"] is True + + + +def test_reused_run_info_does_not_keep_old_core_terminal(mocker): + from threading import Event + from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo + from nexent.core.agents.run_agent import _agent_run_thread + + info=AgentRunInfo(query="New task",observer=MessageObserver(),stop_event=Event(),model_config_list=[], + agent_config=AgentConfig(name="agent",description="Fixture",model_name="fixture",tools=[])) + info.run_result=RunResult(turn_id="old",status="completed",end_reason="runner_answer",final_content="Old") + info.stop_event.set() + mocker.patch("nexent.core.agents.core_adapters.AgentCoreFactory.create") + _agent_run_thread(info) + assert info.run_result is None and info.attempt_outcome=="stopped" diff --git a/test/sdk/core/agents/test_jiuwen_protocol.py b/test/sdk/core/agents/test_jiuwen_protocol.py new file mode 100644 index 0000000000..227a71c5d0 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_protocol.py @@ -0,0 +1,46 @@ +"""Unit contracts for the controlled text execution grammar.""" + +import pytest +from nexent.core.agents.core_adapters.jiuwen_protocol import ( + JiuwenProtocolError, + parse_execution_blocks, +) + + +@pytest.mark.parametrize("text,code", [ + ("add(a=2,b=3)", "add(a=2,b=3)"), + ("Prose\n\nx=2\n\nprint(x)", "x=2\n\nprint(x)"), + ("```\nprint(2)\n```", "print(2)"), +]) +def test_registered_complete_blocks(text, code): + assert parse_execution_blocks(text).code == code + + +@pytest.mark.parametrize("text", ["Answer", "```python\nadd(2,3)\n```", "add(2,3)"]) +def test_reference_text_is_not_executable(text): + assert parse_execution_blocks(text) is None + + +@pytest.mark.parametrize("text", [ + "add(2,3)", "", "print(1)print(2)", + "print(1)", "```print(1)", + "print(1)```print(2)```", " ", + "print(", "print(1)done", + "```python\nprint(1)\n```", +]) +def test_incomplete_ambiguous_or_invalid_blocks_fail_closed(text): + with pytest.raises(JiuwenProtocolError, match="JIUWEN_") as error: + parse_execution_blocks(text) + assert text not in str(error.value) + + + +@pytest.mark.parametrize("text,category", [ + ("print(1)", "JIUWEN_NESTED_EXECUTION_BLOCK"), + ("print(1)", "JIUWEN_NESTED_EXECUTION_BLOCK"), + ("print(1)", "JIUWEN_INCOMPLETE_EXECUTION_BLOCK"), + ("```python\nprint(1)\n```", "JIUWEN_AMBIGUOUS_EXECUTION_BLOCK"), +]) +def test_invalid_code_classification_preserves_execution_boundary(text, category): + with pytest.raises(JiuwenProtocolError, match=category): + parse_execution_blocks(text) diff --git a/test/sdk/core/agents/test_jiuwen_resources.py b/test/sdk/core/agents/test_jiuwen_resources.py new file mode 100644 index 0000000000..48c08e45ae --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_resources.py @@ -0,0 +1,121 @@ +"""Resource bridge validation, loop ownership and captured MCP parameters.""" + +import asyncio +from concurrent.futures import Future +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from jsonschema import ValidationError +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.core_adapters.jiuwen_resources import JiuwenResourceBridge +from nexent.core.agents.core_adapters.mcp import ( + JiuwenMCPCollection, + _ManagedNativeClient, +) +from nexent.core.concurrency import ( + LanePolicy, + ManagedTaskSpec, + RunCancellationScope, + ThreadManager, +) +from nexent.core.concurrency.cancellation import RunTerminated + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arguments,expected", [((2,), 2), (("wrong",), None), ((2, 3), None)]) +async def test_text_bridge_returns_to_owned_loop_and_checks_schema(mocker, arguments, expected): + from openjiuwen.core.runner import Runner + + tool = SimpleNamespace(invoke=AsyncMock(return_value=2)) + mocker.patch.object(Runner.resource_mgr, "get_tool", return_value=tool) + card = SimpleNamespace(name="allowed", id="owned", input_params={ + "type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"]}) + hidden = SimpleNamespace(name="hidden", id="hidden", input_params={}) + agent = SimpleNamespace(ability_manager=SimpleNamespace(list=lambda: [card, hidden])) + scope = RunCancellationScope() + bridge = JiuwenResourceBridge(agent, asyncio.get_running_loop(), scope, {"allowed"}) + bindings = bridge.bindings() + assert set(bindings) == {"allowed"} + manager = ThreadManager("bridge-unit", {"sandbox": LanePolicy(name="sandbox", max_workers=1, max_queue_size=1)}) + manager.start() + try: + execution = manager.submit("sandbox", ManagedTaskSpec(task_name="bridge-call", owner=__name__), + bindings["allowed"], *arguments) + if expected is not None: + assert await asyncio.wrap_future(execution.future) == expected + tool.invoke.assert_awaited_once_with({"a": 2}) + else: + with pytest.raises((ValidationError, ValueError)): + await asyncio.wrap_future(execution.future) + tool.invoke.assert_not_awaited() + scope.cancel() + with pytest.raises(RunTerminated): + bindings["allowed"](2) + finally: + assert not (await manager.shutdown(timeout=2)).stuck_execution_ids + + +@pytest.mark.asyncio +async def test_mcp_configured_values_and_captured_identity_override_model_input(): + manager = Mock() + scope = RunCancellationScope() + user = {"user_id": "captured", "user_groups": ["original"]} + owner = JiuwenMCPCollection(authorized_tools=[], observe=Mock(), manager=manager, user_context=user, + server_parameters=[], cancellation_scope=scope, tool_timeout_seconds=1, close_timeout_seconds=1) + owner._call_tool = Mock(return_value=20) + def submit(lane, spec, forward, *args): + future = Future() + future.set_result(forward(*args)) + return SimpleNamespace(future=future) + manager.submit.side_effect = submit + params = {"b": 4, "user_id": "untrusted-config"} + client = _ManagedNativeClient(owner, "session", "multiply", ["user_id", "user_groups"], params) + user["user_id"] = "later" + user["user_groups"].append("later") + params["b"] = 99 + assert await client.call_tool("multiply", {"a": 5, "b": 99, "user_id": "spoofed"}) == 20 + owner._call_tool.assert_called_once_with("session", "multiply", { + "a": 5, "b": 4, "user_id": "captured", "user_groups": ["original"]}) + assert manager.submit.call_args.args[0] == "model-tool-io" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_function", [False, True]) +async def test_business_entry_awaits_both_async_functions_and_returned_awaitables(async_function): + async def forward(value): + return value + 1 + + info = SimpleNamespace(agent_config=SimpleNamespace(name="fixture"), observer=Mock()) + adapter = JiuwenAdapter(info) + adapter.scope = RunCancellationScope() + adapter._observe_tool = Mock(return_value=nullcontext()) + adapter.manager = ThreadManager("async-resource-unit", { + "model-tool-io": LanePolicy(name="model-tool-io", max_workers=1, max_queue_size=1)}) + adapter.manager.start() + try: + handler = adapter._business_handler("fixture", forward if async_function else lambda value: forward(value)) + assert await handler(value=4) == 5 + info.observer.add_message.assert_called_once() + finally: + assert not (await adapter.manager.shutdown(timeout=2)).stuck_execution_ids + + +@pytest.mark.asyncio +@pytest.mark.parametrize("authorized", [True, False, "empty"]) +async def test_skill_root_and_name_must_both_belong_to_the_same_authorized_snapshot(tmp_path, authorized): + from nexent.core.agents.core_adapters.skills import NexentSkillRail + + first, second = tmp_path / "first", tmp_path / "second" + path = first / ("allowed" if authorized else "second_only") / "SKILL.md" + path.parent.mkdir(parents=True) + path.write_text("authorized content", encoding="utf-8") + rail = NexentSkillRail([str(first), str(second)], ["allowed", "second_only"], + authorized_by_root={} if authorized == "empty" else { + str(first): ["allowed"], str(second): ["second_only"]}) + if authorized is True: + assert await rail._read_skill_text(path) == "authorized content" + else: + with pytest.raises(PermissionError): + await rail._read_skill_text(path) diff --git a/test/sdk/core/model_service/conftest.py b/test/sdk/core/model_service/conftest.py new file mode 100644 index 0000000000..e84ee6784e --- /dev/null +++ b/test/sdk/core/model_service/conftest.py @@ -0,0 +1,70 @@ +"""Shared controlled outlet fixture for provider adapter unit boundaries.""" + +import pytest +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.profiles.composition import compose_provider_model_policy +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_resolution import ( + construct_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, +) +from nexent.core.model_service.profiles.resolver import load_profile_catalog + + +@pytest.fixture(scope="module") +def openai_profile(): + catalog = load_profile_catalog() + ref = VersionedReference(ref="adapter_fixture_v1", version=1) + registry = { + key: [ref] + for key in ("protocol", "auth", "adapter", "code_converter", "native_converter") + } + registry["protocol"] = [OpenAIChatAdapter.protocol_reference] + registry["adapter"] = [OpenAIChatAdapter.reference] + registry["parameter_transform"] = [VersionedReference(ref="identity_v1", version=1)] + policy = compose_provider_model_policy( + catalog, + provider_key="alibaba-cn", + request_model_id="qwen3.5-plus", + model_profile_key=None, + base_bindings={ + key: references[0].model_dump() + for key, references in registry.items() + if key != "parameter_transform" + }, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=registry, + submitted_values={"enable_thinking": False}, + ) + return construct_resolved_model_profile( + policy, + catalog, + tenant_id="tenant", + display_name="outlet", + model_type="llm", + provider_id="alibaba", + connection=OutletConnection( + base_url="https://example.test/v1", + ssl_verify=True, + timeout_seconds=10, + concurrency_limit=None, + credential_ref={"kind": "model_record", "model_id": None}, + ), + connection_rules=ConnectionRules( + credential_kind="model_record", + relative_path="/chat/completions", + method="POST", + headers={}, + ), + context_configuration=ContextConfiguration( + output_reserve_tokens=None, + tokenizer_family=None, + capacity_source="profile", + capability_profile_version=None, + ), + registered_references=registry, + ) diff --git a/test/sdk/core/model_service/test_http_contracts.py b/test/sdk/core/model_service/test_http_contracts.py new file mode 100644 index 0000000000..2cdbfc2a77 --- /dev/null +++ b/test/sdk/core/model_service/test_http_contracts.py @@ -0,0 +1,133 @@ +"""Prepared HTTP boundary validation without external I/O.""" + +import json + +import pytest +from nexent.core.model_service.http_contracts import ( + PreparedHttpRequest, + model_request_url, + validate_headers, +) +from nexent.core.model_service.profiles.resolved_profile import CredentialReference + + +def prepared(**overrides): + values = { + "request_id": "request-1", + "method": "POST", + "base_url": "https://example.test/v1?api-version=1", + "relative_path": "chat/completions", + "headers": {}, + "credential_ref": CredentialReference(kind="none", model_id=None), + "json_body": {"model": "exact-model", "messages": []}, + "timeout_seconds": 10, + "ssl_verify": True, + } + return PreparedHttpRequest(**(values | overrides)) + + +def test_exact_root_query_and_body_are_preserved_and_detached(): + body = { + "model": "exact-model", + "messages": [{"content": "中文"}], + "temperature": 0, + "enable_thinking": False, + } + headers = {"Accept": "application/json"} + request = prepared(json_body=body, headers=headers) + body["messages"][0]["content"] = "mutated" + headers["Accept"] = "changed" + assert request.url == "https://example.test/v1/chat/completions?api-version=1" + assert json.loads(request.body_bytes)["messages"] == [{"content": "中文"}] + assert json.loads(request.body_bytes)["temperature"] == 0 + assert json.loads(request.body_bytes)["enable_thinking"] is False + assert request.headers == {"accept": "application/json"} + with pytest.raises(TypeError): + request.headers["accept"] = "changed" + assert "中文" not in repr(request) + assert "api-version" not in repr(request) + + +@pytest.mark.parametrize( + "root,path", + [ + ("ftp://example.test", "chat"), + ("https://user:password@example.test", "chat"), + ("https://example.test/#fragment", "chat"), + ("https://example.test:99999", "chat"), + ("https://example.test", "https://other.test/chat"), + ("https://example.test", "//other.test/chat"), + ("https://example.test", "../chat"), + ("https://example.test", "x/%2e%2e/chat"), + ("https://example.test", "chat?token=private"), + ("https://example.test", "chat#x"), + ("https://example.test", "chat\\x"), + ("https://example.test", "chat//x"), + ("https://example.test", ""), + ("https://example.test\n", "chat"), + (None, "chat"), + ], +) +def test_url_rejects_credentials_and_root_overrides_with_safe_error(root, path): + with pytest.raises(ValueError, match="^Invalid model HTTP URL$"): + model_request_url(root, path) + + +@pytest.mark.parametrize( + "headers", + [ + {"Authorization": "private"}, + {"Host": "other.test"}, + {"a": "private\n"}, + {"bad name": "private"}, + {"A": "1", "a": "2"}, + {"a": 3}, + None, + {"x-api-key": "private"}, + {"a": "中文"}, + ], +) +def test_fixed_headers_do_not_accept_auth_transport_or_invalid_values(headers): + with pytest.raises(ValueError, match="^Invalid model HTTP headers$"): + validate_headers(headers) + + +def test_explicit_credential_headers_accept_authorization_without_exposing_value(): + assert validate_headers({"Authorization": "Bearer dummy"}, credentials=True) == { + "authorization": "Bearer dummy" + } + + +@pytest.mark.parametrize( + "overrides", + [ + {"timeout_seconds": 0}, + {"timeout_seconds": float("nan")}, + {"timeout_seconds": float("inf")}, + {"timeout_seconds": True}, + {"ssl_verify": 1}, + {"method": "DELETE"}, + {"stream_protocol": "unknown"}, + {"json_body": []}, + {"request_id": " "}, + {"credential_ref": {}}, + ], +) +def test_invalid_prepared_request_is_rejected(overrides): + with pytest.raises(ValueError, match="^Invalid prepared model HTTP request$"): + prepared(**overrides) + + +@pytest.mark.parametrize( + "body", [{"value": float("nan")}, {"value": object()}, {"value": "\ud800"}] +) +def test_invalid_json_body_is_rejected_without_payload_echo(body): + with pytest.raises(ValueError, match="^Invalid model HTTP body$"): + prepared(json_body=body) + + +def test_leading_path_separator_preserves_api_root(): + assert ( + model_request_url("https://example.test/v1", "/chat/completions") + == "https://example.test/v1/chat/completions" + ) diff --git a/test/sdk/core/model_service/test_http_sender.py b/test/sdk/core/model_service/test_http_sender.py new file mode 100644 index 0000000000..fdd090e4bf --- /dev/null +++ b/test/sdk/core/model_service/test_http_sender.py @@ -0,0 +1,395 @@ +"""Isolated transport branches; actual network collaboration is a formal D3.""" + +import asyncio +import json +import socket +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.http_contracts import ( + ModelTransportError, + PreparedHttpRequest, +) +from nexent.core.model_service.http_sender import HttpRequestSender +from nexent.core.model_service.profiles.resolved_profile import CredentialReference +from nexent.core.model_service.sse import SSEEvent + + +def request(**changes): + values = { + "request_id": "r1", + "method": "POST", + "base_url": "https://example.test/v1?api-version=1", + "relative_path": "chat/completions", + "headers": {}, + "json_body": {"model": "exact", "temperature": 0}, + "timeout_seconds": 10, + "ssl_verify": False, + "credential_ref": CredentialReference(kind="model_record", model_id=3), + } + return PreparedHttpRequest(**(values | changes)) + + +def test_frozen_credentials_resolve_once_and_keep_detached_headers(): + headers = {"Authorization": "Bearer dummy-original"} + calls = [] + + def credentials(reference): + calls.append(reference) + return headers + + wire = request() + frozen = HttpRequestSender(credentials).freeze_credentials(wire) + headers["Authorization"] = "Bearer dummy-changed" + assert frozen._headers(wire)["authorization"] == "Bearer dummy-original" + assert frozen._headers(wire)["authorization"] == "Bearer dummy-original" + assert len(calls) == 1 + + +@pytest.mark.parametrize("late", [False, True]) +@pytest.mark.parametrize( + "event", ["connection.connect_tcp.complete", "connection.start_tls.complete"] +) +def test_sync_cancel_shuts_down_owned_or_late_network_socket(clients, late, event): + scope = RunCancellationScope() + connection = Mock(spec=socket.socket) + stream = SimpleNamespace(get_extra_info=lambda _: connection, close=Mock()) + + def handler(wire): + if late: + scope.cancel() + wire.extensions["trace"](event, {"return_value": stream}) + scope.cancel() + return httpx.Response(200, json={"unused": True}) + + owned = clients(handler) + with pytest.raises(RunTerminated): + HttpRequestSender(lambda _: {}).invoke(request(), scope=scope) + connection.shutdown.assert_called_once_with(socket.SHUT_RDWR) + assert stream.close.call_count == int(late) + assert len(owned) == 1 and owned[0].is_closed + + +@pytest.fixture +def clients(monkeypatch): + original_sync, original_async = httpx.Client, httpx.AsyncClient + captured = [] + + def install(handler): + transport = httpx.MockTransport(handler) + + def sync(**kwargs): + assert kwargs == {"verify": False, "timeout": 10, "trust_env": False} + client = original_sync(transport=transport, **kwargs) + captured.append(client) + return client + + def asynchronous(**kwargs): + assert kwargs == {"verify": False, "timeout": 10, "trust_env": False} + client = original_async(transport=transport, **kwargs) + captured.append(client) + return client + + monkeypatch.setattr(httpx, "Client", sync) + monkeypatch.setattr(httpx, "AsyncClient", asynchronous) + return captured + + return install + + +@pytest.mark.asyncio +async def test_sync_async_exact_request_auth_and_owned_close(clients): + seen = [] + + def handler(req): + seen.append(req) + assert str(req.url) == "https://example.test/v1/chat/completions?api-version=1" + assert req.headers["authorization"] == "Bearer dummy-private" + assert req.headers["content-type"] == "application/json" + assert json.loads(req.content) == {"model": "exact", "temperature": 0} + return httpx.Response(200, json={"choices": []}) + + owned = clients(handler) + sender = HttpRequestSender( + lambda ref: ( + {"Authorization": "Bearer dummy-private"} if ref.model_id == 3 else {} + ) + ) + assert sender.invoke(request()).body == b'{"choices":[]}' + assert (await sender.ainvoke(request())).status_code == 200 + assert len(seen) == 2 + assert all(client.is_closed for client in owned) + + +@pytest.mark.parametrize("status", [301, 400, 401, 429, 500]) +@pytest.mark.asyncio +async def test_http_status_exposes_body_only_to_adapter_and_never_retries( + clients, status +): + seen = [] + + def handler(req): + seen.append(req) + return httpx.Response( + status, text="dummy-private", headers={"Location": "https://other.test"} + ) + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + for asynchronous in [False, True]: + with pytest.raises(ModelTransportError, match="^HTTP_STATUS_ERROR$") as caught: + if asynchronous: + await sender.ainvoke(request()) + else: + sender.invoke(request()) + assert caught.value.response.status_code == status + assert caught.value.response.body == b"dummy-private" + assert "dummy-private" not in repr(caught.value.response) + assert "dummy-private" not in repr(caught.value) + assert len(seen) == 2 + assert all(client.is_closed for client in owned) + + +@pytest.mark.parametrize( + "error,category", + [(httpx.ReadTimeout, "HTTP_TIMEOUT"), (httpx.ConnectError, "HTTP_NETWORK_ERROR")], +) +@pytest.mark.asyncio +async def test_transport_errors_are_safe_and_close_owned_clients( + clients, error, category +): + def handler(req): + raise error("dummy-private", request=req) + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + for asynchronous in [False, True]: + with pytest.raises(ModelTransportError, match=f"^{category}$") as caught: + if asynchronous: + await sender.ainvoke(request()) + else: + sender.invoke(request()) + assert caught.value.__suppress_context__ + assert all(client.is_closed for client in owned) + + +@pytest.mark.asyncio +async def test_sync_async_stream_frames_and_early_close(clients): + owned = clients( + lambda req: httpx.Response( + 200, + text="data: first\n\ndata: second\n\n", + headers={"Content-Type": "text/event-stream; charset=utf-8"}, + ) + ) + sender = HttpRequestSender(lambda ref: {}) + with sender.stream(request(stream_protocol="sse")) as events: + assert next(events) == SSEEvent("first") + async with sender.astream(request(stream_protocol="sse")) as events: + assert [event async for event in events] == [ + SSEEvent("first"), + SSEEvent("second"), + ] + assert all(client.is_closed for client in owned) + + +@pytest.mark.parametrize( + "body,content_type,category", + [ + ("data: truncated", "text/event-stream", "SSE_INCOMPLETE_EVENT"), + ("data: " + "x" * 30, "text/event-stream", "SSE_EVENT_TOO_LARGE"), + ("data: wrong\n\n", "application/json", "HTTP_STREAM_FORMAT_INVALID"), + ], +) +@pytest.mark.asyncio +async def test_invalid_streams_fail_explicitly_and_close( + clients, body, content_type, category +): + owned = clients( + lambda req: httpx.Response( + 200, text=body, headers={"content-type": content_type} + ) + ) + sender = HttpRequestSender(lambda ref: {}, max_event_bytes=20) + with ( + pytest.raises(ModelTransportError, match=f"^{category}$"), + sender.stream(request(stream_protocol="sse")) as events, + ): + list(events) + with pytest.raises(ModelTransportError, match=f"^{category}$"): + async with sender.astream(request(stream_protocol="sse")) as events: + [event async for event in events] + assert all(client.is_closed for client in owned) + + +@pytest.mark.asyncio +async def test_whole_response_limits_are_not_silent_truncation(clients): + owned = clients(lambda req: httpx.Response(200, content=b"12345")) + sender = HttpRequestSender(lambda ref: {}, max_response_bytes=4) + with pytest.raises(ModelTransportError, match="^HTTP_RESPONSE_TOO_LARGE$"): + sender.invoke(request()) + with pytest.raises(ModelTransportError, match="^HTTP_RESPONSE_TOO_LARGE$"): + await sender.ainvoke(request()) + assert all(client.is_closed for client in owned) + + +def test_pre_cancelled_scope_does_not_resolve_credentials_or_send(): + scope = RunCancellationScope() + scope.cancel() + sender = HttpRequestSender( + lambda ref: pytest.fail("Credential resolution should not occur") + ) + with pytest.raises(RunTerminated): + sender.invoke(request(), scope=scope) + + +def test_bad_credential_callback_is_sanitized_before_creating_client(): + def resolver(ref): + raise ValueError("dummy-private") + + with pytest.raises(ModelTransportError, match="^CREDENTIAL_RESOLUTION_FAILED$"): + HttpRequestSender(resolver).invoke(request()) + with pytest.raises(ModelTransportError, match="^CREDENTIAL_HEADER_CONFLICT$"): + HttpRequestSender(lambda ref: {"Accept": "private"}).invoke( + request(headers={"Accept": "application/json"}) + ) + + +@pytest.mark.asyncio +async def test_scope_cancellation_interrupts_blocked_async_stream_and_closes(clients): + entered = asyncio.Event() + + class Blocked(httpx.AsyncByteStream): + closed = False + + async def __aiter__(self): + entered.set() + await asyncio.Event().wait() + yield b"unreachable" + + async def aclose(self): + self.closed = True + + stream = Blocked() + owned = clients( + lambda req: httpx.Response( + 200, stream=stream, headers={"content-type": "text/event-stream"} + ) + ) + scope = RunCancellationScope() + sender = HttpRequestSender(lambda ref: {}) + + async def run(): + async with sender.astream( + request(stream_protocol="sse"), scope=scope + ) as events: + return [event async for event in events] + + task = asyncio.create_task(run()) + await asyncio.wait_for(entered.wait(), 2) + scope.cancel() + with pytest.raises(RunTerminated): + await asyncio.wait_for(task, 2) + assert stream.closed + assert all(client.is_closed for client in owned) + assert scope._closers == {} + + +@pytest.mark.parametrize( + "changes", + [{"max_response_bytes": 0}, {"max_error_bytes": True}, {"max_event_bytes": -1}], +) +def test_sender_limits_require_positive_integers(changes): + with pytest.raises(ValueError, match="positive integers"): + HttpRequestSender(lambda ref: {}, **changes) + + +@pytest.mark.asyncio +async def test_closed_stream_iterators_cannot_emit_buffered_events(clients): + owned = clients( + lambda req: httpx.Response( + 200, + content=b"data: one\n\ndata: two\n\n", + headers={"content-type": "text/event-stream"}, + ) + ) + sender = HttpRequestSender(lambda ref: {}) + with sender.stream(request(stream_protocol="sse")) as sync_events: + assert next(sync_events) == SSEEvent("one") + assert list(sync_events) == [] + async with sender.astream(request(stream_protocol="sse")) as async_events: + assert await anext(async_events) == SSEEvent("one") + assert [event async for event in async_events] == [] + assert all(client.is_closed for client in owned) + + +def test_scope_cancel_stops_even_already_buffered_sync_events(clients): + owned = clients( + lambda req: httpx.Response( + 200, + content=b"data: one\n\ndata: two\n\n", + headers={"content-type": "text/event-stream"}, + ) + ) + sender = HttpRequestSender(lambda ref: {}) + scope = RunCancellationScope() + with ( + pytest.raises(RunTerminated), + sender.stream(request(stream_protocol="sse"), scope=scope) as events, + ): + assert next(events) == SSEEvent("one") + scope.cancel() + next(events) + assert all(client.is_closed for client in owned) + assert scope._closers == {} + + +@pytest.mark.asyncio +async def test_error_streams_preserve_status_and_private_body_without_replay(clients): + calls = [] + + def handler(req): + calls.append(req) + return httpx.Response(429, text="dummy-private") + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + with ( + pytest.raises(ModelTransportError, match="^HTTP_STATUS_ERROR$") as sync_error, + sender.stream(request(stream_protocol="sse")), + ): + pytest.fail("Status error must precede iteration") + with pytest.raises(ModelTransportError, match="^HTTP_STATUS_ERROR$") as async_error: + async with sender.astream(request(stream_protocol="sse")): + pytest.fail("Status error must precede iteration") + assert ( + sync_error.value.response.body + == async_error.value.response.body + == b"dummy-private" + ) + assert len(calls) == 2 + assert all(client.is_closed for client in owned) + + +@pytest.mark.asyncio +async def test_tls_cause_is_classified_without_message_matching_or_reducing_verification( + clients, +): + import ssl + + def handler(req): + try: + raise ssl.SSLCertVerificationError("dummy-private") + except ssl.SSLError as cause: + raise httpx.ConnectError("dummy-private", request=req) from cause + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + with pytest.raises(ModelTransportError, match="^HTTP_TLS_ERROR$"): + sender.invoke(request()) + with pytest.raises(ModelTransportError, match="^HTTP_TLS_ERROR$"): + await sender.ainvoke(request()) + assert all(client.is_closed for client in owned) diff --git a/test/sdk/core/model_service/test_legacy_configuration_export.py b/test/sdk/core/model_service/test_legacy_configuration_export.py new file mode 100644 index 0000000000..e470514ca6 --- /dev/null +++ b/test/sdk/core/model_service/test_legacy_configuration_export.py @@ -0,0 +1,46 @@ +"""Old persisted values are interpreted by the same pure functions used by Agent construction.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.compatibility.legacy_config import legacy_agent_request_parameters + + +def test_export_preserves_null_sampling_custom_overrides_and_old_effort_selection(): + row = {"model_type": "llm", "base_url": "https://custom.invalid/v1", "model_factory": "custom", + "temperature": None, "top_p": 0, "extra_params": {"enable_thinking": True, + "reasoning_effort": "high", "__custom__": {"reasoning_effort": "medium", "flag": False}}} + before = deepcopy(row) + body = legacy_agent_request_parameters(row, model_id="exact", capability=None) + assert body == {"temperature": None, "top_p": 0, "enable_thinking": True, + "reasoning_effort": "medium", "flag": False} + assert row == before + + +def test_budget_range_uses_existing_clamp_and_alibaba_wire_mapping(): + row = {"model_type": "llm", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "model_factory": "dashscope", "temperature": 0.2, "top_p": None, + "extra_params": {"enable_thinking": True, "reasoning_budget_tokens": 8}} + capability = {"source": "models_dev", "status": "supported", "provider_id": "alibaba-cn", + "controls": [{"type": "budget_tokens", "min": 32, "max": 64}]} + assert legacy_agent_request_parameters(row, model_id="qwen3.5-plus", capability=capability) == { + "temperature": 0.2, "top_p": None, "enable_thinking": True, "thinking_budget": 32} + assert row["extra_params"]["reasoning_budget_tokens"] == 8 + + +def test_credential_columns_are_never_read_by_exporter(): + class Row(dict): + def get(self, key, *args): + assert key not in {"api_key", "access_token", "model_appid"} + return super().get(key, *args) + + row = Row(model_type="llm", model_factory="custom", base_url="https://custom.invalid/v1", extra_params=None) + assert legacy_agent_request_parameters(row, model_id="exact", capability=None) == { + "temperature": None, "top_p": None} + + +@pytest.mark.parametrize("row", [{"model_type": "vlm"}, {"model_type": "embedding"}, + {"model_type": "llm", "extra_params": []}]) +def test_out_of_scope_or_invalid_records_are_rejected(row): + with pytest.raises(ValueError): + legacy_agent_request_parameters(row, model_id="exact", capability=None) diff --git a/test/sdk/core/model_service/test_legacy_invocation_options.py b/test/sdk/core/model_service/test_legacy_invocation_options.py new file mode 100644 index 0000000000..1eb16c2076 --- /dev/null +++ b/test/sdk/core/model_service/test_legacy_invocation_options.py @@ -0,0 +1,121 @@ +"""Legacy aliases preserve declared bindings without changing persisted defaults.""" + +import pytest +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.compatibility.invocation_options import ( + LegacyInvocationOptions, +) +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from smolagents.models import CODEAGENT_RESPONSE_FORMAT + + +@pytest.fixture +def declared_profile(openai_profile): + snapshot = openai_profile.model_dump() + for key, schema in ( + ("response_format", {"type": "object", "required": ["type"], "properties": { + "type": {"enum": ["json_object", "json_schema"]}, + }}), + ): + snapshot["parameter_rules"].append({ + "key": key, "schema": schema, "visible": False, "editable": True, + "required": False, "allowed_scopes": ["outlet"], "ui_group": "legacy", + "owner": "provider", "source": {"ref": "controlled_protocol_fixture", "version": 1}, + "api_binding": {"path": "/" + key, "transform_ref": "identity_v1", "transform_version": 1}, + }) + return ResolvedModelProfile.model_validate(snapshot) + + +def test_code_stop_and_structured_format_use_existing_adapter_bindings(declared_profile): + stops = ["Observation:", "Calling tools:"] + profile = LegacyInvocationOptions({}, stops, CODEAGENT_RESPONSE_FORMAT).resolve(declared_profile) + request = ModelRequest( + request_id="legacy", outlet_id=profile.outlet_id, config_revision=profile.config_revision, + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "code"}])], + ) + body = OpenAIChatAdapter().prepare_http_request(request, profile).json_body + assert body["stop"] == stops and body["response_format"] == CODEAGENT_RESPONSE_FORMAT + assert "stop" not in declared_profile.parameter_values + assert "response_format" not in declared_profile.parameter_values + stops.append("changed") + assert profile.parameter_values["stop"] == ["Observation:", "Calling tools:"] + + +def test_none_aliases_preserve_saved_defaults_and_absence(declared_profile): + snapshot = declared_profile.model_dump() + snapshot["parameter_values"]["stop"] = ["saved"] + original = ResolvedModelProfile.model_validate(snapshot) + result = LegacyInvocationOptions({}).resolve(original) + assert result.parameter_values["stop"] == ["saved"] + assert "response_format" not in result.parameter_values + + +@pytest.mark.parametrize("stops", [["x"], []]) +def test_identical_duplicate_alias_is_allowed_but_conflict_rejected(declared_profile, stops): + result = LegacyInvocationOptions({"stop": stops}, stops).resolve(declared_profile) + assert result.parameter_values["stop"] == stops + with pytest.raises(ParameterValidationError, match="LEGACY_PARAMETER_CONFLICT"): + LegacyInvocationOptions({"stop": ["other"]}, stops).resolve(declared_profile) + + +@pytest.mark.parametrize("options", [ + LegacyInvocationOptions({}, [1]), LegacyInvocationOptions({}, [""]), + LegacyInvocationOptions({}, response_format={"type": "arbitrary"}), + LegacyInvocationOptions({"messages": []}), +]) +def test_invalid_or_undeclared_options_are_rejected(declared_profile, options): + with pytest.raises(ParameterValidationError): + options.resolve(declared_profile) + + +def test_protocol_capability_does_not_invent_stop_or_format_rules(openai_profile): + data = openai_profile.model_dump() + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] != "stop"] + openai_profile = ResolvedModelProfile.model_validate(data) + for options in (LegacyInvocationOptions({}, ["stop"]), LegacyInvocationOptions({}, response_format={"type": "json_object"})): + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + options.resolve(openai_profile) + + +@pytest.mark.parametrize("stops", [["Observation:", "Calling tools:"], [""], "END", []]) +def test_builtin_profile_accepts_actual_code_and_planning_stops(openai_profile, stops): + profile = LegacyInvocationOptions({}, stop_sequences=stops).resolve(openai_profile) + request = ModelRequest(request_id="builtin-stop", outlet_id=profile.outlet_id, + config_revision=profile.config_revision, + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "hello"}])]) + assert OpenAIChatAdapter().prepare_http_request(request, profile).json_body["stop"] == stops + assert "stop" not in openai_profile.parameter_values + + +@pytest.mark.parametrize("stops", [["ok", 1], [""], "", {"stop": "END"}]) +def test_builtin_stop_rule_rejects_invalid_code_values(openai_profile, stops): + with pytest.raises(ParameterValidationError): + LegacyInvocationOptions({}, stop_sequences=stops).resolve(openai_profile) + + +def test_builtin_qwen35_does_not_claim_code_json_schema_support(openai_profile): + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + LegacyInvocationOptions({}, response_format=CODEAGENT_RESPONSE_FORMAT).resolve(openai_profile) + + +@pytest.mark.parametrize("custom", [[], 1, {1: True}, {"": True}]) +def test_invalid_legacy_custom_container_rejected(declared_profile, custom): + with pytest.raises(ParameterValidationError, match="LEGACY_CUSTOM_PARAMETERS_INVALID"): + LegacyInvocationOptions({}, custom_overrides=custom).resolve(declared_profile) + + +def test_legacy_custom_overrides_preserve_false_zero_and_explicit_removal(declared_profile): + data = declared_profile.model_dump() + data["extra_api_parameters"] = [{"path": "/old", "value": "remove"}] + original = ResolvedModelProfile.model_validate(data) + updated = LegacyInvocationOptions({}, custom_overrides={"old": None, "flag": False, "count": 0}).resolve(original) + assert updated.extra_api_parameters == [{"path": "/flag", "value": False}, {"path": "/count", "value": 0}] + assert original.extra_api_parameters == [{"path": "/old", "value": "remove"}] + + +@pytest.mark.parametrize("custom", [{"messages": []}, {"api_key": "blocked"}, {"enable_thinking": False}]) +def test_legacy_custom_cannot_override_protected_or_declared_paths(declared_profile, custom): + with pytest.raises(ParameterValidationError): + LegacyInvocationOptions({}, custom_overrides=custom).resolve(declared_profile) diff --git a/test/sdk/core/model_service/test_legacy_reasoning_export.py b/test/sdk/core/model_service/test_legacy_reasoning_export.py new file mode 100644 index 0000000000..51363efdd9 --- /dev/null +++ b/test/sdk/core/model_service/test_legacy_reasoning_export.py @@ -0,0 +1,54 @@ +"""Migration reads legacy request fields without initializing inference clients.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.compatibility.legacy_reasoning import ( + legacy_reasoning_wire_parameters, +) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_deepseek_explicit_toggle_uses_existing_thinking_object(enabled): + extra = {"enable_thinking": enabled, "custom": {"flag": False}} + before = deepcopy(extra) + result = legacy_reasoning_wire_parameters(model_id="deepseek-chat", base_url="https://api.deepseek.com/v1", + model_factory="deepseek", capability={"source": "models_dev", "status": "supported", "provider_id": "deepseek"}, + extra_body=extra) + assert result == {"thinking": {"type": "enabled" if enabled else "disabled"}, "custom": {"flag": False}} + assert extra == before + + +def test_alibaba_numeric_budget_keeps_legacy_priority_over_effort(): + result = legacy_reasoning_wire_parameters(model_id="qwen3.5-plus", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", model_factory="alibaba-cn", + capability={"source": "models_dev", "status": "supported", "provider_id": "alibaba-cn", + "controls": [{"type": "budget_tokens"}]}, + effort="high", budget=64, extra_body={"enable_thinking": True}) + assert result == {"enable_thinking": True, "thinking_budget": 64} + assert "reasoning_effort" not in result + + +def test_self_hosted_qwen_retains_old_chat_template_mapping(): + result = legacy_reasoning_wire_parameters(model_id="Qwen/ExactModel", base_url="https://custom.invalid/v1", + model_factory="OpenAI-API-Compatible", extra_body={"enable_thinking": False, + "chat_template_kwargs": {"custom": 0}}) + assert result == {"chat_template_kwargs": {"custom": 0, "enable_thinking": False}} + + +def test_custom_body_override_wins_exactly_as_in_openai_client_merge(): + result = legacy_reasoning_wire_parameters(model_id="custom", base_url="https://custom.invalid/v1", + model_factory="custom", effort="high", extra_body={"reasoning_effort": "medium", "custom": [False, 0]}) + assert result == {"reasoning_effort": "medium", "custom": [False, 0]} + + +def test_export_does_not_allocate_http_clients_or_modify_input(monkeypatch): + import httpx + + monkeypatch.setattr(httpx, "Client", lambda *args, **kwargs: pytest.fail("Migration must not allocate HTTP clients")) + monkeypatch.setattr(httpx, "AsyncClient", lambda *args, **kwargs: pytest.fail("Migration must not allocate HTTP clients")) + body = {"nested": {"values": [False, 0]}} + result = legacy_reasoning_wire_parameters(model_id="custom", base_url="https://custom.invalid/v1", + model_factory="custom", extra_body=body) + result["nested"]["values"].append("changed") + assert body == {"nested": {"values": [False, 0]}} diff --git a/test/sdk/core/model_service/test_model_service.py b/test/sdk/core/model_service/test_model_service.py new file mode 100644 index 0000000000..56bbba3520 --- /dev/null +++ b/test/sdk/core/model_service/test_model_service.py @@ -0,0 +1,425 @@ +"""Logical call policy with controlled transport failures and real adapter contracts.""" + +import asyncio +import json +import time +from contextlib import asynccontextmanager, contextmanager +from types import SimpleNamespace + +import pytest +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.concurrency import ModelConcurrencyLimiter +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.http_contracts import ( + ModelTransportError, + RawHttpResponse, +) +from nexent.core.model_service.service import ( + ModelRequestRetryPolicy, + ModelService, + ModelServiceError, +) +from nexent.core.model_service.sse import SSEEvent + + +def complete(): + return RawHttpResponse( + 200, + {}, + json.dumps( + { + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "answer", + }, + "finish_reason": "stop", + } + ] + } + ).encode(), + ) + + +def frame(delta=None, finish=None): + return SSEEvent( + json.dumps( + {"choices": [{"index": 0, "delta": delta or {}, "finish_reason": finish}]} + ) + ) + + +class Sender: + def __init__(self, outcomes): + self.outcomes = iter(outcomes) + self.calls = [] + self.closed = 0 + self.frozen = 0 + + def freeze_credentials(self, request): + self.frozen += 1 + return self + + def invoke(self, request, **kwargs): + self.calls.append(request) + outcome = next(self.outcomes) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + async def ainvoke(self, request, **kwargs): + return self.invoke(request, **kwargs) + + @contextmanager + def stream(self, request, **kwargs): + def events(): + for event in self.invoke(request): + if isinstance(event, BaseException): + raise event + yield event + + try: + yield events() + finally: + self.closed += 1 + + @asynccontextmanager + async def astream(self, request, **kwargs): + async def events(): + for event in self.invoke(request): + if isinstance(event, BaseException): + raise event + yield event + + try: + yield events() + finally: + self.closed += 1 + + +@pytest.fixture +def setup(openai_profile): + values = openai_profile.model_dump() + values["bindings"]["adapter"] = OpenAIChatAdapter.reference.model_dump() + values["connection"]["concurrency_limit"] = 1 + profile = type(openai_profile).model_validate(values) + request = ModelRequest( + request_id="logical-request", + outlet_id=None, + config_revision=1, + messages=[ + ModelMessage( + semantic_role="user", content_parts=[{"type": "text", "text": "hello"}] + ), + ], + ) + limiter = ModelConcurrencyLimiter() + + def service(sender, **kwargs): + return ModelService( + lambda _: sender, + limiter=limiter, + retry_policy=SimpleNamespace( + max_attempts=3, + calculate_backoff=lambda _: 0, + ), + **kwargs, + ) + + return profile, request, limiter, service + + +@pytest.mark.asyncio +async def test_sync_async_retry_preserves_snapshot_and_releases_permits(setup): + profile, request, limiter, service = setup + for asynchronous in [False, True]: + sender = Sender([ModelTransportError("HTTP_NETWORK_ERROR"), complete()]) + model = service(sender) + result = ( + await model.ainvoke(request, profile) + if asynchronous + else model.invoke(request, profile) + ) + assert result.content_parts == [{"type": "text", "text": "answer"}] + assert len(sender.calls) == 2 and sender.frozen == 1 + assert sender.calls[0].body_bytes == sender.calls[1].body_bytes + assert ( + sender.calls[0].request_id + == sender.calls[1].request_id + == request.request_id + ) + assert not limiter._entries + + +@pytest.mark.parametrize("status", [401, 403, 400, 404, 422]) +def test_nonretryable_provider_error_is_safe_and_releases_permit(setup, status): + profile, request, limiter, service = setup + sender = Sender( + [ + ModelTransportError( + "HTTP_STATUS_ERROR", + response=RawHttpResponse( + status, + {}, + b'{"error":{"message":"private-key-123"}}', + ), + ) + ] + ) + with pytest.raises(ModelServiceError) as caught: + service(sender).invoke(request, profile) + assert caught.value.error.attempt == 1 and not caught.value.error.will_retry + assert "private-key" not in str(caught.value) + caught.value.error.model_dump_json() + assert len(sender.calls) == 1 and not limiter._entries + + +def test_default_retry_count_is_three_and_exhaustion_is_explicit(setup): + profile, request, limiter, _ = setup + sender = Sender([ModelTransportError("HTTP_NETWORK_ERROR") for _ in range(3)]) + policy = SimpleNamespace( + max_attempts=ModelRequestRetryPolicy().max_attempts, + calculate_backoff=lambda _: 0, + ) + with pytest.raises(ModelServiceError) as caught: + ModelService(lambda _: sender, limiter=limiter, retry_policy=policy).invoke( + request, profile + ) + assert len(sender.calls) == caught.value.error.attempt == 3 + assert caught.value.error.retryable and not caught.value.error.will_retry + assert not limiter._entries + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "delta", + [ + {"content": "visible"}, + {"reasoning_content": "thinking"}, + { + "tool_calls": [ + { + "index": 0, + "id": "call-one", + "function": {"name": "tool", "arguments": "{"}, + } + ], + }, + ], +) +async def test_any_visible_fragment_prevents_sync_async_stream_replay(setup, delta): + profile, request, limiter, service = setup + request = request.model_copy(update={"stream": True}) + for asynchronous in [False, True]: + sender = Sender([[frame(delta), ModelTransportError("HTTP_NETWORK_ERROR")]]) + if asynchronous: + async with service(sender).astream(request, profile) as events: + result = [event async for event in events] + else: + with service(sender).stream(request, profile) as events: + result = list(events) + assert result[-1].type == "error" and not result[-1].error.will_retry + assert len(sender.calls) == 1 and sender.closed == 1 + assert [event.sequence for event in result] == list(range(1, len(result) + 1)) + assert not limiter._entries + + +@pytest.mark.asyncio +async def test_pre_output_stream_retry_then_completion_and_early_exit(setup): + profile, request, limiter, service = setup + request = request.model_copy(update={"stream": True}) + for asynchronous in [False, True]: + sender = Sender( + [ + ModelTransportError("HTTP_NETWORK_ERROR"), + [ + frame({"content": "answer"}), + frame(finish="stop"), + SSEEvent("[DONE]"), + ], + ] + ) + if asynchronous: + async with service(sender).astream(request, profile) as events: + result = [event async for event in events] + else: + with service(sender).stream(request, profile) as events: + result = list(events) + assert [event.type for event in result] == ["text_delta", "message_completed"] + assert [event.attempt for event in result] == [2, 2] + assert len(sender.calls) == sender.closed == 2 and not limiter._entries + sender = Sender( + [[frame({"content": "first"}), frame(finish="stop"), SSEEvent("[DONE]")]] + ) + async with service(sender).astream(request, profile) as events: + assert (await anext(events)).type == "text_delta" + assert sender.closed == 1 and not limiter._entries + with pytest.raises(StopAsyncIteration): + await anext(events) + + +@pytest.mark.asyncio +async def test_cancelled_run_never_starts_or_retries(setup): + profile, request, limiter, service = setup + sender = Sender([]) + scope = RunCancellationScope() + scope.cancel() + with pytest.raises(RunTerminated): + await service(sender).ainvoke(request, profile, scope=scope) + assert not sender.calls and not sender.frozen and not limiter._entries + + +def test_retry_after_exceeding_run_budget_is_not_retried(setup): + profile, request, limiter, service = setup + sender = Sender( + [ + ModelTransportError( + "HTTP_STATUS_ERROR", + response=RawHttpResponse( + 429, + {"Retry-After": "30"}, + b"{}", + ), + ) + ] + ) + with pytest.raises(ModelServiceError) as caught: + service(sender).invoke(request, profile, deadline=time.monotonic() + 5) + assert ( + caught.value.error.category == "MODEL_RATE_LIMITED" + and not caught.value.error.will_retry + ) + assert len(sender.calls) == 1 and not limiter._entries + + +@pytest.mark.asyncio +async def test_error_before_first_model_event_has_valid_identity(setup): + profile, request, limiter, service = setup + request = request.model_copy(update={"stream": True}) + for asynchronous in [False, True]: + sender = Sender([[SSEEvent("invalid-json")]]) + if asynchronous: + async with service(sender).astream(request, profile) as events: + result = [event async for event in events] + else: + with service(sender).stream(request, profile) as events: + result = list(events) + assert len(result) == 1 and result[0].type == "error" + assert result[0].error.category == "MODEL_PROTOCOL_ERROR" + assert result[0].sequence == 1 and not limiter._entries + + +@pytest.mark.asyncio +async def test_async_task_cancellation_releases_admission(setup): + profile, request, limiter, service = setup + sender = Sender([]) + entered = asyncio.Event() + + async def blocked(*args, **kwargs): + entered.set() + await asyncio.Event().wait() + + sender.ainvoke = blocked + task = asyncio.create_task(service(sender).ainvoke(request, profile)) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not limiter._entries + + +@pytest.mark.asyncio +async def test_async_run_deadline_interrupts_blocked_call_without_retry(setup, caplog): + profile, request, limiter, service = setup + sender = Sender([]) + cancelled = asyncio.Event() + + async def blocked(*args, **kwargs): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + sender.ainvoke = blocked + with pytest.raises(ModelServiceError) as caught: + await service(sender).ainvoke( + request, profile, deadline=time.monotonic() + 0.05 + ) + assert caught.value.error.category == "MODEL_RUN_DEADLINE" + assert caught.value.error.attempt == 1 and not caught.value.error.will_retry + assert cancelled.is_set() and not limiter._entries + assert [record.message for record in caplog.records] == ["model_request_timeout"] + assert caplog.records[0].levelname == "WARNING" + + +def test_stream_uses_snapshot_detached_at_context_entry(setup): + profile, request, limiter, service = setup + request = request.model_copy(update={"stream": True}) + sender = Sender( + [[frame({"content": "answer"}), frame(finish="stop"), SSEEvent("[DONE]")]] + ) + with service(sender).stream(request, profile) as events: + profile.parameter_values["enable_thinking"] = True + request.messages[0].content_parts[0]["text"] = "changed" + assert list(events)[-1].type == "message_completed" + body = json.loads(sender.calls[0].body_bytes) + assert body["enable_thinking"] is False + assert body["messages"][0]["content"] == "hello" + assert not limiter._entries + + +@pytest.mark.asyncio +@pytest.mark.parametrize('asynchronous', [False, True]) +@pytest.mark.parametrize('policy,expected_calls,terminal', [ + ('accept', 1, 'message_completed'), + ('fail', 1, 'error'), + ('retry', 3, 'error'), +]) +async def test_empty_response_policy_shares_physical_retry_budget(setup, asynchronous, policy, expected_calls, terminal): + profile, request, limiter, factory = setup + request = request.model_copy(update={'stream': True}) + # A network failure and two empty completions consume the same three attempts. + empty = [frame(finish='stop'), SSEEvent('[DONE]')] + outcomes = [ModelTransportError('HTTP_NETWORK_ERROR'), empty, empty] if policy == 'retry' else [empty] + sender = Sender(outcomes) + model = factory(sender) + if asynchronous: + async with model.astream(request, profile, empty_response_policy=policy) as stream: + events = [event async for event in stream] + else: + with model.stream(request, profile, empty_response_policy=policy) as stream: + events = list(stream) + assert events[-1].type == terminal + if terminal == 'error': + assert events[-1].error.category == 'MODEL_EMPTY_RESPONSE' + assert events[-1].error.attempt == expected_calls + assert not events[-1].error.will_retry + assert len(sender.calls) == expected_calls and sender.frozen == 1 + assert all(call.body_bytes == sender.calls[0].body_bytes for call in sender.calls) + assert not limiter._entries + + +@pytest.mark.asyncio +@pytest.mark.parametrize('asynchronous', [False, True]) +@pytest.mark.parametrize('delta,finish,terminal', [ + ({'reasoning_content': 'thought'}, 'stop', 'error'), + ({'content': ' '}, 'stop', 'error'), + ({}, 'length', 'error'), + ({'refusal': 'cannot comply'}, 'stop', 'message_completed'), + ({'content': 'answer'}, 'stop', 'message_completed'), +]) +async def test_empty_policy_never_replays_visible_output_or_nonstop_finish(setup, asynchronous, delta, finish, terminal): + profile, request, limiter, factory = setup + request = request.model_copy(update={'stream': True}) + sender = Sender([[frame(delta), frame(finish=finish), SSEEvent('[DONE]')]]) + model = factory(sender) + if asynchronous: + async with model.astream(request, profile, empty_response_policy='retry') as stream: + events = [event async for event in stream] + else: + with model.stream(request, profile, empty_response_policy='retry') as stream: + events = list(stream) + assert events[-1].type == terminal + assert len(sender.calls) == 1 and not limiter._entries diff --git a/test/sdk/core/model_service/test_openai_chat_adapter.py b/test/sdk/core/model_service/test_openai_chat_adapter.py new file mode 100644 index 0000000000..91b62fb8d2 --- /dev/null +++ b/test/sdk/core/model_service/test_openai_chat_adapter.py @@ -0,0 +1,442 @@ +"""Isolated request/response protocol proof, without sending or executing tools.""" + +import json +from uuid import UUID + +import pytest +from nexent.core.model_service.adapters.openai_chat import ( + ModelAdapterError, + OpenAIChatAdapter, +) +from nexent.core.model_service.contracts import ( + ModelMessage, + ModelRequest, + ToolCall, + ToolDefinition, +) +from nexent.core.model_service.http_contracts import RawHttpResponse + + +@pytest.fixture +def profile(openai_profile): + return openai_profile + + +def request(profile, **changes): + return ModelRequest( + **( + { + "request_id": "r1", + "outlet_id": profile.outlet_id, + "config_revision": profile.config_revision, + "messages": [ + ModelMessage( + semantic_role="user_input", + content_parts=[{"type": "text", "text": "Hello"}], + ) + ], + } + | changes + ) + ) + + +def response(message=None, finish="stop", **changes): + body = { + "id": "response-1", + "model": "exact-response-model", + "choices": [ + { + "index": 0, + "message": message + if message is not None + else {"role": "assistant", "content": "Hello"}, + "finish_reason": finish, + } + ], + } + body.update(changes) + return RawHttpResponse(200, {}, json.dumps(body).encode()) + + +def test_prepare_exact_profile_parameters_roles_and_media_without_mutation(profile): + media = [ + {"type": "text", "text": "View", "cache_control": {"type": "ephemeral"}}, + {"type": "image_url", "image_url": {"url": "https://example.test/image.png"}}, + ] + req = request( + profile, + messages=[ + ModelMessage( + semantic_role="platform_instruction", + content_parts=[{"type": "text", "text": "rules"}], + ), + ModelMessage(semantic_role="user_input", content_parts=media), + ], + ) + prepared = OpenAIChatAdapter().prepare_http_request(req, profile) + body = json.loads(prepared.body_bytes) + assert prepared.url == "https://example.test/v1/chat/completions" + assert body["model"] == "qwen3.5-plus" + assert body["messages"] == [ + {"role": "system", "content": "rules"}, + {"role": "user", "content": media}, + ] + assert body["enable_thinking"] is False + assert req.messages[1].content_parts == media + assert "api_key" not in body + + +def test_multiple_text_parts_and_cache_metadata_remain_distinct(profile): + parts = [{"type": "text", "text": "one"}, {"type": "text", "text": "two"}] + req = request( + profile, messages=[ModelMessage(semantic_role="user", content_parts=parts)] + ) + assert ( + json.loads(OpenAIChatAdapter().prepare_http_request(req, profile).body_bytes)[ + "messages" + ][0]["content"] + == parts + ) + + +def test_decode_separates_content_reasoning_refusal_and_usage_without_double_counting( + profile, +): + raw = response( + { + "role": "assistant", + "content": "body", + "reasoning_content": "thought", + "refusal": "refused", + "extra_field": "preserved", + }, + usage={ + "prompt_tokens": 10, + "completion_tokens": 3, + "total_tokens": 13, + "prompt_tokens_details": {"cached_tokens": 4}, + "completion_tokens_details": {"reasoning_tokens": 2}, + }, + ) + result = OpenAIChatAdapter().decode_response(raw, request(profile), profile) + assert result.content_parts == [{"type": "text", "text": "body"}] + assert result.reasoning_parts == [{"type": "text", "text": "thought"}] + assert result.refusal == "refused" + assert result.usage.input_tokens == 10 and result.usage.output_tokens == 3 + assert ( + result.usage.total_tokens == 13 + and result.usage.cached_input_tokens == 4 + and result.usage.reasoning_tokens == 2 + ) + assert result.provider_metadata["unmapped_message_fields"] == { + "extra_field": "preserved" + } + assert "thought" not in repr(result) + assert "body" not in repr(result) + + +def test_unknown_finish_and_missing_usage_stay_unknown(profile): + result = OpenAIChatAdapter().decode_response( + response(finish="new_provider_finish"), request(profile), profile + ) + assert result.finish_reason == "new_provider_finish" + assert result.usage.origin == "unknown" and result.usage.total_tokens is None + partial = OpenAIChatAdapter().decode_response( + response(usage={"prompt_tokens": 0}), request(profile), profile + ) + assert partial.usage.input_tokens == 0 + assert partial.usage.total_tokens is None + + +@pytest.mark.parametrize( + "arguments,parsed,error", + [ + ("{}", {}, None), + ('{"value":0,"flag":false}', {"value": 0, "flag": False}, None), + ("[1]", None, "INVALID_TOOL_ARGUMENTS"), + ("{broken", None, "INVALID_TOOL_ARGUMENTS"), + ('{"x":1,"x":2}', None, "INVALID_TOOL_ARGUMENTS"), + ], +) +def test_complete_tools_preserve_raw_provider_ids_and_invalid_arguments( + profile, arguments, parsed, error +): + raw = response( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "provider-call-id-longer-than-local-uuid-" * 2, + "type": "function", + "function": {"name": "search", "arguments": arguments}, + } + ], + }, + finish="tool_calls", + ) + result = OpenAIChatAdapter().decode_response(raw, request(profile), profile) + call = result.tool_calls[0] + assert isinstance(call.local_call_id, UUID) + assert call.provider_call_id == "provider-call-id-longer-than-local-uuid-" * 2 + assert ( + call.raw_arguments == arguments + and call.parsed_arguments == parsed + and call.argument_error == error + ) + assert result.content_parts == [] + + +@pytest.mark.parametrize( + "usage", + [ + {"prompt_tokens": True}, + {"completion_tokens": -1}, + {"total_tokens": 2.0}, + {"prompt_tokens_details": []}, + {"completion_tokens_details": {"reasoning_tokens": "2"}}, + False, + ], +) +def test_invalid_usage_is_not_coerced_or_echoed(profile, usage): + with pytest.raises(ModelAdapterError, match="^MODEL_USAGE_INVALID$"): + OpenAIChatAdapter().decode_response( + response(usage=usage), request(profile), profile + ) + + +@pytest.mark.parametrize( + "body,category", + [ + (b'{"choices":[]}', "MODEL_RESPONSE_INVALID"), + (b'{"choices":[],"choices":[]}', "MODEL_RESPONSE_INVALID"), + ( + b'{"choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}', + "MODEL_RESPONSE_INCOMPLETE", + ), + ( + b'{"choices":[{"index":false,"message":{"role":"assistant"},"finish_reason":"stop"}]}', + "MODEL_RESPONSE_INVALID", + ), + ( + b'{"choices":[{"index":0,"message":{"role":"user"},"finish_reason":"stop"}]}', + "MODEL_RESPONSE_INVALID", + ), + ( + b'{"choices":[{"index":0,"message":{"role":"assistant","tool_calls":false},"finish_reason":"stop"}]}', + "MODEL_TOOL_CALL_INVALID", + ), + (b"dummy-private", "MODEL_RESPONSE_INVALID"), + (b"\xff", "MODEL_RESPONSE_INVALID"), + ], +) +def test_invalid_response_is_explicit_and_sanitized(profile, body, category): + with pytest.raises(ModelAdapterError, match=f"^{category}$") as caught: + OpenAIChatAdapter().decode_response( + RawHttpResponse(200, {}, body), request(profile), profile + ) + assert caught.value.__cause__ is None + + +@pytest.mark.parametrize( + "role,category", + [("developer", "MODEL_ROLE_UNSUPPORTED"), ("tool", "MODEL_MESSAGE_INVALID")], +) +def test_unsupported_roles_or_missing_tool_link_are_not_silently_rewritten( + profile, role, category +): + with pytest.raises(ModelAdapterError, match=f"^{category}$"): + OpenAIChatAdapter().prepare_http_request( + request(profile, messages=[ModelMessage(semantic_role=role)]), profile + ) + + +def test_stale_snapshot_cannot_prepare_request(profile): + with pytest.raises(ModelAdapterError, match="^MODEL_SNAPSHOT_MISMATCH$"): + OpenAIChatAdapter().prepare_http_request( + request(profile, config_revision=2), profile + ) + + +@pytest.mark.parametrize("ref,version", [("other_protocol", 1), ("openai_chat_completions", 2)]) +def test_protocol_reference_must_match_the_registered_adapter(profile, ref, version): + snapshot = profile.model_dump() + snapshot["bindings"]["protocol"] = {"ref": ref, "version": version} + mismatched = type(profile).model_validate(snapshot) + with pytest.raises(ModelAdapterError, match="^MODEL_PROTOCOL_UNSUPPORTED$"): + OpenAIChatAdapter().prepare_http_request(request(mismatched), mismatched) + + +def test_unknown_native_tool_support_does_not_try_native_protocol(profile): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = "unknown" + unknown = type(profile).model_validate(snapshot) + with pytest.raises(ModelAdapterError, match="^NATIVE_TOOL_PROTOCOL_UNAVAILABLE$"): + OpenAIChatAdapter().prepare_http_request( + request( + unknown, + tools=[ + ToolDefinition( + name="search", + description="test", + input_schema={"type": "object"}, + ) + ], + ), + unknown, + ) + + +@pytest.mark.parametrize("status", ["unknown", "unsupported"]) +@pytest.mark.parametrize("history_kind", ["call", "result"]) +def test_native_history_cannot_bypass_capability_check_without_tool_definitions( + profile, status, history_kind +): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = status + unavailable = type(profile).model_validate(snapshot) + call = ToolCall( + provider_call_id="provider-original", index=0, name="search", raw_arguments="{}" + ) + message = ( + ModelMessage(semantic_role="assistant_history", tool_calls=[call]) + if history_kind == "call" + else ModelMessage( + semantic_role="tool_result", + tool_call_id="provider-original", + content_parts=[{"type": "text", "text": "result"}], + ) + ) + req = request(unavailable, messages=[message]) + assert req.tools == [] + with pytest.raises(ModelAdapterError, match="^NATIVE_TOOL_PROTOCOL_UNAVAILABLE$"): + OpenAIChatAdapter().prepare_http_request(req, unavailable) + assert req.messages == [message] + + +def test_tool_history_keeps_exact_links_and_reasoning_replay_requires_declared_adapter( + profile, +): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = "supported" + profile = type(profile).model_validate(snapshot) + call = ToolCall( + provider_call_id="provider-original", + index=0, + name="search", + raw_arguments='{"query":"x"}', + parsed_arguments={"query": "x"}, + ) + messages = [ + ModelMessage( + semantic_role="assistant_history", + tool_calls=[call], + reasoning_parts=[{"type": "text", "text": "thought"}], + ), + ModelMessage( + semantic_role="tool_result", + tool_call_id="provider-original", + content_parts=[{"type": "text", "text": "result"}], + ), + ] + req = request(profile, messages=messages) + with pytest.raises(ModelAdapterError, match="^MODEL_REASONING_FORMAT_UNSUPPORTED$"): + OpenAIChatAdapter().prepare_http_request(req, profile) + body = json.loads( + OpenAIChatAdapter(reasoning_replay=True) + .prepare_http_request(req, profile) + .body_bytes + ) + assert body["messages"][0]["tool_calls"][0]["id"] == "provider-original" + assert body["messages"][0]["reasoning_content"] == "thought" + assert body["messages"][0]["content"] is None + assert body["messages"][1] == { + "role": "tool", + "tool_call_id": "provider-original", + "content": "result", + } + + +def test_native_tool_definitions_are_only_wire_schemas_and_stream_usage_is_requested( + profile, +): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = "supported" + supported = type(profile).model_validate(snapshot) + tool = ToolDefinition( + name="search", + description="Search", + input_schema={"type": "object", "properties": {"query": {"type": "string"}}}, + ) + prepared = OpenAIChatAdapter().prepare_http_request( + request(supported, tools=[tool], stream=True), supported + ) + body = json.loads(prepared.body_bytes) + assert body["tools"] == [ + { + "type": "function", + "function": { + "name": "search", + "description": "Search", + "parameters": tool.input_schema, + }, + } + ] + assert body["stream_options"] == {"include_usage": True} + assert prepared.stream_protocol == "sse" + + +@pytest.mark.parametrize("arguments", ['{"x":NaN}', '{"x":1e1000}']) +def test_nonfinite_tool_arguments_are_not_treated_as_valid_json(profile, arguments): + raw = response( + { + "role": "assistant", + "tool_calls": [ + { + "id": "call", + "type": "function", + "function": {"name": "search", "arguments": arguments}, + } + ], + }, + finish="tool_calls", + ) + call = ( + OpenAIChatAdapter() + .decode_response(raw, request(profile), profile) + .tool_calls[0] + ) + assert call.argument_error == "INVALID_TOOL_ARGUMENTS" + assert call.parsed_arguments is None + assert call.raw_arguments == arguments + + +def test_duplicate_complete_tool_ids_are_explicit_protocol_failure(profile): + call = { + "id": "same", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INVALID$"): + OpenAIChatAdapter().decode_response( + response( + {"role": "assistant", "tool_calls": [call, call]}, finish="tool_calls" + ), + request(profile), + profile, + ) + + +@pytest.mark.parametrize( + "part", [{"type": "unsupported", "text": "x"}, {"type": "text", "text": 1}] +) +def test_unsupported_or_invalid_content_is_not_silently_flattened(profile, part): + with pytest.raises(ModelAdapterError, match="^MODEL_CONTENT_UNSUPPORTED$"): + OpenAIChatAdapter().prepare_http_request( + request( + profile, + messages=[ModelMessage(semantic_role="user", content_parts=[part])], + ), + profile, + ) diff --git a/test/sdk/core/model_service/test_openai_stream.py b/test/sdk/core/model_service/test_openai_stream.py new file mode 100644 index 0000000000..a4e8dfae45 --- /dev/null +++ b/test/sdk/core/model_service/test_openai_stream.py @@ -0,0 +1,365 @@ +"""Controlled stream state without network, retry or tool execution.""" + +import json + +import pytest +from nexent.core.model_service.adapters.openai_chat import ( + ModelAdapterError, + OpenAIChatAdapter, +) +from nexent.core.model_service.adapters.openai_stream import OpenAIStreamDecoder +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.sse import SSEEvent + + +@pytest.fixture +def stream_request(openai_profile): + return ModelRequest( + request_id="r1", + outlet_id=openai_profile.outlet_id, + config_revision=openai_profile.config_revision, + messages=[ + ModelMessage( + semantic_role="user", content_parts=[{"type": "text", "text": "hello"}] + ) + ], + stream=True, + ) + + +def frame(delta=None, finish=None, **extra): + body = { + "id": "response-id", + "model": "provider-exact", + "choices": [{"index": 0, "delta": delta or {}, "finish_reason": finish}], + } + return SSEEvent(json.dumps(body | extra)) + + +def tool(index, name=None, arguments=None, call_id=None): + raw = {"index": index, "function": {}} + if name is not None: + raw["function"]["name"] = name + if arguments is not None: + raw["function"]["arguments"] = arguments + if call_id is not None: + raw["id"] = call_id + return raw + + +def test_interleaved_reasoning_text_and_tools_complete_with_stable_ids( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + events = decoder.push(frame({"role": "assistant", "reasoning_content": "thinking"})) + events += decoder.push( + frame( + { + "content": "body", + "tool_calls": [ + tool(2, "sea", '{"q":', "call-two"), + tool(0, "calc", '{"n":', "call-zero"), + ], + } + ) + ) + events += decoder.push( + frame( + { + "tool_calls": [ + tool(0, arguments="0}"), + tool(2, name="rch", arguments='"x"}'), + ] + }, + finish="tool_calls", + ) + ) + assert not any(event.type == "message_completed" for event in events) + assert all( + event.tool_call_delta is None + or not hasattr(event.tool_call_delta, "parsed_arguments") + for event in events + ) + events += decoder.push( + SSEEvent( + json.dumps( + { + "choices": [], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 4, + "total_tokens": 14, + }, + } + ) + ) + ) + events += decoder.push(SSEEvent("[DONE]")) + assert [event.sequence for event in events] == list(range(1, len(events) + 1)) + assert len({event.message_id for event in events}) == 1 + assert events[0].type == "reasoning_delta" and events[0].delta == "thinking" + assert events[1].type == "text_delta" and events[1].content_kind == "text" + complete = events[-1].message + assert ( + events[-1].type == "message_completed" + and complete.message_id == events[-1].message_id + ) + assert complete.reasoning_parts == [{"type": "text", "text": "thinking"}] + assert complete.content_parts == [{"type": "text", "text": "body"}] + assert [ + (call.index, call.name, call.provider_call_id, call.parsed_arguments) + for call in complete.tool_calls + ] == [(0, "calc", "call-zero", {"n": 0}), (2, "search", "call-two", {"q": "x"})] + ids = { + event.tool_call_delta.index: event.tool_call_delta.local_call_id + for event in events + if event.type == "tool_call_delta" + } + assert all(call.local_call_id == ids[call.index] for call in complete.tool_calls) + assert complete.usage.total_tokens == 14 + assert decoder.finish() == [] + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_CLOSED$"): + decoder.push(frame({"content": "late"})) + + +def test_refusal_and_unknown_delta_do_not_become_body_or_tool_calls( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + events = decoder.push( + frame( + {"refusal": "no", "future_field": {"data": "retained"}}, + finish="content_filter", + ) + ) + assert events[0].content_kind == "refusal" + complete = decoder.push(SSEEvent("[DONE]"))[0].message + assert ( + complete.refusal == "no" + and complete.content_parts == [] + and complete.tool_calls == [] + ) + assert complete.provider_metadata["unmapped_delta_fields"] == [ + {"future_field": {"data": "retained"}} + ] + + +@pytest.mark.parametrize( + "mode", + [ + "no_finish", + "no_done", + "only_done", + "tool_missing_name", + "tool_missing_id", + "tool_finish_without_calls", + ], +) +def test_incomplete_stream_never_promotes_tools_to_completion( + openai_profile, stream_request, mode +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + if mode == "no_finish": + assert decoder.push(frame({"content": "partial"}))[0].type == "text_delta" + elif mode == "no_done": + decoder.push(frame({"content": "body"}, finish="stop")) + elif mode.startswith("tool_missing"): + decoder.push( + frame( + { + "tool_calls": [ + tool( + 0, + name="search" if mode.endswith("id") else None, + arguments="{}", + call_id="id" if mode.endswith("name") else None, + ) + ] + }, + finish="tool_calls", + ) + ) + elif mode == "tool_finish_without_calls": + decoder.push(frame({}, finish="tool_calls")) + with pytest.raises(ModelAdapterError): + if mode in {"no_finish", "no_done"}: + decoder.finish() + else: + decoder.push(SSEEvent("[DONE]")) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_CLOSED$"): + decoder.push(frame({"content": "repair"}, finish="stop")) + + +def test_explicit_optional_done_still_requires_finish(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile, require_done=False) + decoder.push(frame({"content": "complete"}, finish="provider_new_finish")) + complete = decoder.finish()[0] + assert complete.message.finish_reason == "provider_new_finish" + assert complete.message.usage.total_tokens is None + missing = OpenAIStreamDecoder(stream_request, openai_profile, require_done=False) + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INCOMPLETE$"): + missing.finish() + + +@pytest.mark.parametrize( + "delta", + [ + {"content": 2}, + {"reasoning_content": []}, + {"role": "user"}, + {"tool_calls": False}, + {"tool_calls": [{"index": False}]}, + {"tool_calls": [{"index": 0, "function": {"arguments": 1}}]}, + ], +) +def test_invalid_delta_types_are_not_coerced(openai_profile, stream_request, delta): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + with pytest.raises(ModelAdapterError): + decoder.push(frame(delta)) + + +@pytest.mark.parametrize( + "data", + [ + '{"choices":[],"choices":[]}', + '{"choices":[false]}', + '{"choices":[],"x":NaN}', + "dummy-private", + "[]", + ], +) +def test_invalid_json_and_envelope_are_sanitized(openai_profile, stream_request, data): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INVALID$") as caught: + decoder.push(SSEEvent(data)) + assert "dummy-private" not in str(caught.value) + + +def test_identity_conflicts_and_post_finish_delta_fail(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({"content": "visible"})) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_IDENTITY_CONFLICT$"): + decoder.push(frame({"content": "different"}, id="other-response")) + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({}, finish="stop")) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_AFTER_FINISH$"): + decoder.push(frame({"content": "late"})) + + +def test_tool_id_conflict_is_not_a_second_execution_identity( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({"tool_calls": [tool(0, "search", "{", "original")]})) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_IDENTITY_CONFLICT$"): + decoder.push(frame({"tool_calls": [tool(0, call_id="other")]})) + + +def test_total_stream_and_tool_limits_cannot_be_bypassed_by_small_chunks( + openai_profile, stream_request +): + sample = frame({"content": "中"}) + decoder = OpenAIStreamDecoder( + stream_request, openai_profile, max_message_bytes=len(sample.data.encode()) * 2 + ) + decoder.push(sample) + decoder.push(sample) + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_TOO_LARGE$"): + decoder.push(sample) + decoder = OpenAIStreamDecoder(stream_request, openai_profile, max_tool_calls=1) + decoder.push(frame({"tool_calls": [tool(100000, "one", "{}", "one")]})) + with pytest.raises(ModelAdapterError, match="^MODEL_TOOL_CALL_LIMIT$"): + decoder.push(frame({"tool_calls": [tool(2, "two", "{}", "two")]})) + + +def test_duplicate_usage_is_explicit_without_replacing_reported_values( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + first = decoder.push(frame({}, finish="stop", usage={"prompt_tokens": 0})) + assert first[0].usage.input_tokens == 0 + with pytest.raises(ModelAdapterError, match="^MODEL_USAGE_CONFLICT$"): + decoder.push( + SSEEvent(json.dumps({"choices": [], "usage": {"prompt_tokens": 1}})) + ) + + +@pytest.mark.parametrize( + "options", [{"require_done": 1}, {"max_message_bytes": 0}, {"max_tool_calls": True}] +) +def test_invalid_limits_or_protocol_declarations_fail( + openai_profile, stream_request, options +): + with pytest.raises(ValueError): + OpenAIStreamDecoder(stream_request, openai_profile, **options) + + +@pytest.mark.asyncio +async def test_sync_async_adapter_interfaces_share_ordered_completion_and_stop_at_done( + openai_profile, stream_request +): + frames = [frame({"content": "body"}, finish="stop"), SSEEvent("[DONE]")] + + def sync_frames(): + yield from frames + pytest.fail("The adapter should stop at the declared completion marker") + + async def async_frames(): + for item in frames: + yield item + pytest.fail("The adapter should stop at the declared completion marker") + + adapter = OpenAIChatAdapter() + sync = list(adapter.decode_stream(sync_frames(), stream_request, openai_profile)) + asynchronous = [ + event + async for event in adapter.adecode_stream( + async_frames(), stream_request, openai_profile + ) + ] + assert ( + [event.type for event in sync] + == [event.type for event in asynchronous] + == ["text_delta", "message_completed"] + ) + assert ( + sync[-1].message.content_parts + == asynchronous[-1].message.content_parts + == [{"type": "text", "text": "body"}] + ) + + +@pytest.mark.asyncio +async def test_both_adapter_interfaces_report_truncated_stream( + openai_profile, stream_request +): + adapter = OpenAIChatAdapter() + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INCOMPLETE$"): + list( + adapter.decode_stream( + [frame({"content": "partial"})], stream_request, openai_profile + ) + ) + + async def partial(): + yield frame({"content": "partial"}) + + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INCOMPLETE$"): + [ + event + async for event in adapter.adecode_stream( + partial(), stream_request, openai_profile + ) + ] + + + +def test_identical_terminal_usage_is_idempotent(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + usage = {"prompt_tokens": 0, "completion_tokens": 2, "total_tokens": 2} + first = decoder.push(frame({"content": "OK"}, finish="stop", usage=usage)) + repeated = decoder.push(SSEEvent(json.dumps({"choices": [], "usage": usage}))) + assert sum(event.type == "usage" for event in first + repeated) == 1 + completed = decoder.push(SSEEvent("[DONE]")) + assert completed[-1].message.usage.input_tokens == 0 + assert completed[-1].message.usage.output_tokens == 2 diff --git a/test/sdk/core/model_service/test_outlet_storage.py b/test/sdk/core/model_service/test_outlet_storage.py new file mode 100644 index 0000000000..8344374da1 --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_storage.py @@ -0,0 +1,433 @@ +"""Whole-row outlet codec validates ownership, versions and saved configuration.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + resolve_instruction_mapping, +) +from nexent.core.model_service.profiles.outlet_storage import ( + ProfileStorageError, + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from pydantic import ValidationError + +REFERENCE = VersionedReference(ref="fixture_v1", version=1) +ROLE = VersionedReference(ref="system_only_v1", version=1) +REGISTRY = { + key: [REFERENCE] + for key in ( + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ) +} + + +def rule(key, **changes): + value = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "fixture"}, + "api_binding": { + "path": "/" + key, + "transform_ref": "fixture_v1", + "transform_version": 1, + }, + } + value.update(changes) + return value + + +@pytest.fixture +def document(): + return { + "schema_version": 1, + "outlet_id": 12, + "config_revision": 1, + "identity": { + "tenant_id": "tenant", + "display_name": "model display", + "model_type": "llm", + "provider_id": "custom-provider", + "request_model_id": "Exact/MODEL", + "provider_key": None, + "model_profile_key": None, + }, + "connection": { + "base_url": "http://localhost:8000/v1", + "ssl_verify": False, + "timeout_seconds": 120, + "concurrency_limit": None, + "credential_ref": {"kind": "model_record", "model_id": 12}, + }, + "connection_rules": { + "credential_kind": "model_record", + "relative_path": "/chat/completions", + "method": "POST", + "headers": {"Accept": "application/json"}, + }, + "context_configuration": { + "output_reserve_tokens": None, + "tokenizer_family": None, + "capacity_source": "operator", + "capability_profile_version": None, + }, + "capabilities": {}, + "instruction_mapping": resolve_instruction_mapping(ROLE).model_dump( + mode="json" + ), + "parameter_rules": [ + rule("temperature", schema={"type": "number"}, fixed_value=0), + rule("custom", default_value="default"), + ], + "parameter_values": {"temperature": 0, "custom": None}, + "extra_api_parameters": [{"path": "/vendor/option", "value": False}], + "bindings": { + **{ + key: REFERENCE.model_dump() + for key in REGISTRY + if key != "parameter_transform" + }, + "role_mapping": ROLE.model_dump(), + }, + "provenance": { + "atomic_refs": {"model": None, "provider": None, "provider_model": None}, + "interpretation_versions": { + "source_parser": None, + "source_mapping": None, + "supplement_schema": None, + "composer": None, + }, + "overrides": [], + "field_sources": [], + "provider_metadata": {}, + }, + } + + +def encode(document): + return encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), registered_references=REGISTRY + ) + + +def decode(row): + return decode_resolved_model_profile(row, registered_references=REGISTRY) + + +def test_complete_snapshot_round_trip_without_duplicate_values_or_credentials(document): + profile = ResolvedModelProfile.model_validate(document) + row = encode(document) + assert "api_key" not in row + assert "max_tokens" not in row + assert row["temperature"] == 0 + assert row["extra_params"]["parameter_values"] == {"custom": None} + assert row["resolved_profile"]["parameter_rules"][0]["fixed_to_saved_value"] is True + assert "fixed_value" not in row["resolved_profile"]["parameter_rules"][0] + assert "protocol" not in row["resolved_profile"]["bindings"] + assert row["protocol_id"] == "fixture_v1" + assert row["resolved_profile"]["bindings"]["protocol_version"] == 1 + assert decode(row).model_dump() == profile.model_dump() + row["api_key"] = "credential-private" + assert decode(row).model_dump() == profile.model_dump() + assert "credential-private" not in repr(decode(row)) + + +def test_creation_before_database_id_assignment_and_no_default_application(document): + document["outlet_id"] = None + document["connection"]["credential_ref"]["model_id"] = None + document["parameter_values"].pop("custom") + row = encode(document) + assert "model_id" not in row + row["model_id"] = 27 + profile = decode(row) + assert profile.outlet_id == profile.connection.credential_ref.model_id == 27 + assert "custom" not in profile.parameter_values + + +def test_snapshot_load_is_detached_and_ignores_unrelated_row_fields(document): + row = encode(document) + row["create_time"] = object() + original = deepcopy(row["resolved_profile"]) + restored = decode(row) + restored.parameter_rules[0]["schema"]["type"] = "string" + restored.connection_rules.headers["Accept"] = "changed" + assert row["resolved_profile"] == original + + +@pytest.mark.parametrize( + "key", + [ + "model_id", + "resolved_profile", + "protocol_id", + "extra_params", + "timeout_seconds", + "config_revision", + ], +) +def test_incomplete_rows_fail_without_catalog_fallback(document, key): + row = encode(document) + row.pop(key) + with pytest.raises(ProfileStorageError, match="^PROFILE_STORAGE_INVALID$"): + decode(row) + + +@pytest.mark.parametrize( + "key,value", + [ + ("profile_schema_version", None), + ("profile_schema_version", True), + ("profile_schema_version", 2), + ("model_id", True), + ("model_id", 0), + ("config_revision", 0), + ("temperature", True), + ("protocol_id", "x" * 31), + ("timeout_seconds", -1), + ], +) +def test_invalid_row_types_and_versions_are_rejected(document, key, value): + row = encode(document) + row[key] = value + with pytest.raises(ProfileStorageError): + decode(row) + + +@pytest.mark.parametrize( + "group", + [ + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ], +) +def test_unavailable_implementation_versions_fail_both_save_and_load(document, group): + row = encode(document) + registry = {key: refs for key, refs in REGISTRY.items() if key != group} + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), + registered_references=registry, + ) + with pytest.raises(ProfileStorageError): + decode_resolved_model_profile(row, registered_references=registry) + + +@pytest.mark.parametrize( + "group,value", + [ + ("extra_params", {"temperature": 0}), + ("extra_params", None), + ("resolved_profile", None), + ("resolved_profile", {"api_key": "private"}), + ], +) +def test_legacy_or_bare_configuration_is_not_treated_as_resolved( + document, group, value +): + row = encode(document) + row[group] = value + with pytest.raises(ProfileStorageError) as captured: + decode(row) + assert "private" not in str(captured.value) + + +def test_duplicate_storage_and_conflicting_null_marker_fail(document): + row = encode(document) + row["extra_params"]["parameter_values"]["temperature"] = 0 + with pytest.raises(ProfileStorageError): + decode(row) + row = encode(document) + row["resolved_profile"]["explicit_null_parameters"] = ["temperature"] + with pytest.raises(ProfileStorageError): + decode(row) + + +def test_protected_adapter_paths_are_enforced_at_both_storage_boundaries(document): + row = encode(document) + with pytest.raises(ProfileStorageError): + decode_resolved_model_profile( + row, registered_references=REGISTRY, protected_paths=["/vendor"] + ) + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), + registered_references=REGISTRY, + protected_paths=["/temperature"], + ) + + +@pytest.mark.parametrize( + "change", + [ + lambda d: d["connection"]["credential_ref"].update(model_id=99), + lambda d: d["connection"].update(api_key="private"), + lambda d: d["provenance"].update(unknown=True), + lambda d: d["bindings"].update(role_mapping={"ref": "other", "version": 1}), + lambda d: d.update(schema_version=True), + lambda d: d["identity"].update(request_model_id="x" * 101), + lambda d: d["extra_api_parameters"].append( + {"path": "/model", "value": "other"} + ), + ], +) +def test_complete_contract_rejects_unowned_or_conflicting_fields(document, change): + change(document) + with pytest.raises((ValueError, ValidationError)): + ResolvedModelProfile.model_validate(document) + + +def test_serialization_revalidates_mutated_nested_values(document): + profile = ResolvedModelProfile.model_validate(document) + profile.parameter_values["temperature"] = 1 + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def test_atomic_identity_restores_from_provenance_without_source_reads( + document, monkeypatch +): + from pathlib import Path + + reference = { + "key": "provider/family", + "catalog_revision": "catalog-one", + "file_path": "providers/family/provider.toml", + "content_sha256": "a" * 64, + } + document["provenance"]["atomic_refs"]["provider"] = reference + document["identity"]["provider_key"] = "provider/family" + row = encode(document) + + def unexpected_read(*args, **kwargs): + pytest.fail("Saved outlet loading must not read source files") + + monkeypatch.setattr(Path, "read_text", unexpected_read) + restored = decode(row) + assert restored.identity.provider_key == restored.provider_key == "provider/family" + assert "identity" not in row["resolved_profile"] + assert restored.provenance.atomic_refs.provider.content_sha256 == "a" * 64 + + +@pytest.mark.parametrize( + "key,control,schema,value", + [ + ( + "enable_thinking", + {"mode": "toggle", "values": [False, True]}, + {"type": "boolean", "enum": [False, True]}, + False, + ), + ( + "reasoning_effort", + {"mode": "effort", "values": [None, "high"]}, + {"type": ["string", "null"], "enum": [None, "high"]}, + None, + ), + ( + "reasoning_budget_tokens", + {"mode": "budget_tokens", "min": 0, "max": 10, "special_values": [-1]}, + {"type": "integer"}, + -1, + ), + ], +) +def test_full_row_round_trip_includes_reasoning_domains_and_null_presence( + document, key, control, schema, value +): + document["capabilities"] = { + "reasoning": {"status": "supported", "controls": [control]} + } + document["parameter_rules"].append(rule(key, schema=schema)) + document["parameter_values"][key] = value + row = encode(document) + assert key not in row["extra_params"]["parameter_values"] + assert decode(row).parameter_values[key] == value + assert row["resolved_profile"]["explicit_null_parameters"] == ( + [key] if value is None else [] + ) + + +@pytest.mark.parametrize( + "control,value", + [ + ({"mode": "toggle", "values": [False]}, True), + ({"mode": "budget_tokens", "min": 0, "max": 10}, -1), + ({"mode": "budget_tokens", "min": 0, "max": 10}, 11), + ], +) +def test_saved_values_cannot_exceed_declared_reasoning_domain(document, control, value): + key = ( + "enable_thinking" if control["mode"] == "toggle" else "reasoning_budget_tokens" + ) + document["capabilities"] = { + "reasoning": {"status": "supported", "controls": [control]} + } + document["parameter_rules"].append(rule(key)) + document["parameter_values"][key] = value + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_control_declaration_requires_a_rule_even_when_value_is_not_sent(document): + document["capabilities"] = { + "reasoning": {"controls": [{"mode": "toggle", "values": [False, True]}]} + } + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + document["parameter_rules"].append(rule("enable_thinking")) + assert "enable_thinking" not in decode(encode(document)).parameter_values + + +def test_schema_with_missing_parameter_binding_is_not_persistable(document): + document["parameter_rules"][0]["api_binding"].pop("path") + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_duplicate_protocol_reference_in_remaining_json_fails(document): + row = encode(document) + row["resolved_profile"]["bindings"]["protocol"] = REFERENCE.model_dump() + with pytest.raises(ProfileStorageError): + decode(row) + + +def test_oversized_complete_configuration_is_rejected(document): + profile = ResolvedModelProfile.model_validate(document) + profile.provenance.provider_metadata["filler"] = "x" * (1024 * 1024) + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def test_atomic_key_cannot_contradict_provenance(document): + document["identity"]["model_profile_key"] = "unrelated-model" + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_integer_sampling_schema_survives_postgresql_float_column(document): + document["parameter_rules"][0]["schema"] = {"type": "integer"} + row = encode(document) + row["temperature"] = ( + 0.0 # PostgreSQL FLOAT returns a Python float for an integer insert. + ) + profile = decode(row) + assert type(profile.parameter_values["temperature"]) is int + assert profile.parameter_values["temperature"] == 0 diff --git a/test/sdk/core/model_service/test_protocol_diagnostics.py b/test/sdk/core/model_service/test_protocol_diagnostics.py new file mode 100644 index 0000000000..0515ef77ed --- /dev/null +++ b/test/sdk/core/model_service/test_protocol_diagnostics.py @@ -0,0 +1,59 @@ +"""Protocol diagnostics retain fixed categories without exposing provider material.""" + +import pytest + +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError +from nexent.core.model_service.errors import normalize_model_error +from nexent.core.model_service.http_contracts import ModelTransportError, RawHttpResponse + + +@pytest.mark.parametrize("code", ["MODEL_TOOL_CALL_INVALID", "MODEL_USAGE_CONFLICT", "MODEL_RESPONSE_INCOMPLETE"]) +def test_known_protocol_category_is_logged_without_changing_public_contract(caplog, code): + result = normalize_model_error(ModelAdapterError(code), request_id="internal-request") + assert result.category == "MODEL_PROTOCOL_ERROR" + assert result.safe_message == "The model response could not be processed." + assert not result.retryable and not result.will_retry + records = [r for r in caplog.records if "model_protocol_error" in r.message] + assert len(records) == 1 + assert records[0].protocol_code == code + assert records[0].request_id == "internal-request" + + +def test_unknown_adapter_category_is_not_logged_verbatim(caplog): + secret = "PRIVATE_PROVIDER_BODY_AND_SIGNED_URL" + normalize_model_error(ModelAdapterError(secret), request_id="internal-request") + assert secret not in caplog.text + records = [r for r in caplog.records if "model_protocol_error" in r.message] + assert len(records) == 1 and records[0].protocol_code == "UNCLASSIFIED" + + +def test_provider_error_response_stays_private(caplog): + result = normalize_model_error( + ModelAdapterError( + "MODEL_PROVIDER_ERROR", + response=RawHttpResponse( + 401, + {}, + b'{"error":{"message":"PRIVATE_PROVIDER_BODY"}}', + ), + ), + request_id="internal-request", + ) + assert result.category == "MODEL_AUTHENTICATION_ERROR" + assert "PRIVATE_PROVIDER_BODY" not in caplog.text + assert not any("model_protocol_error" in r.message for r in caplog.records) + + +@pytest.mark.parametrize("code", ["SSE_INCOMPLETE_EVENT", "SSE_INVALID_TEXT", "HTTP_STREAM_FORMAT_INVALID"]) +def test_framing_diagnostic_is_preserved_without_transport_retry(caplog, code): + result = normalize_model_error(ModelTransportError(code), request_id="internal-request") + assert result.category == "MODEL_PROTOCOL_ERROR" and result.scope == "transport" + assert not result.retryable + assert len(caplog.records) == 1 and caplog.records[0].protocol_code == code + + +def test_untrusted_exception_text_is_not_in_diagnostics(caplog): + result = normalize_model_error(ValueError("PRIVATE_RESPONSE_TEXT"), request_id="internal-request") + assert result.category == "MODEL_PROTOCOL_ERROR" + assert "PRIVATE_RESPONSE_TEXT" not in caplog.text + assert caplog.records[0].protocol_code == "UNCLASSIFIED" diff --git a/test/sdk/core/model_service/test_resource_conversion.py b/test/sdk/core/model_service/test_resource_conversion.py new file mode 100644 index 0000000000..d64e65dd1f --- /dev/null +++ b/test/sdk/core/model_service/test_resource_conversion.py @@ -0,0 +1,127 @@ +"""Authorized resources cross a stable conversion boundary without execution.""" + +import json + +import pytest +from nexent.core.agent_runtime_support.contracts import ( + ResourceItem, + RuntimeResourceView, +) +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.contracts import ModelMessage, ToolCall, ToolDefinition +from nexent.core.model_service.resource_conversion import ( + ResourceConverterRegistry, + ResourceRequestConverter, +) +from pydantic import ValidationError + + +def item(role, text, order, source="history", **kwargs): + return ResourceItem(message=ModelMessage( + semantic_role=role, content_parts=[{"type": "text", "text": text}], **kwargs, + ), source=source, order=order) + + +def resources(): + return RuntimeResourceView( + instructions=[item("application_instruction", "skill", 2, "activated_skill_instruction"), + item("platform_instruction", "platform", 1, "platform_instruction")], + references=[item("reference_data", "knowledge", 3, "knowledge")], + history=[item("assistant_history", "progress", 4, tool_calls=[ToolCall( + provider_call_id="call-1", index=0, name="lookup", raw_arguments='{"q":"x"}', + )])], + tool_observations=[item("tool_result", "result", 5, "tool", tool_call_id="call-1")], + authorized_metadata=[item("reference_data", "approved metadata", 6, "metadata")], + current_user_input=item("user_input", "continue", 7, "current_input"), + tool_definitions=[ToolDefinition(name="lookup", description="Find data", input_schema={"type": "object"})], + ) + + +def test_native_conversion_preserves_every_resource_and_tool_association(openai_profile): + view = resources() + before = view.model_dump() + converted = ResourceRequestConverter(native=True).convert(view, openai_profile, request_id="request", stream=True) + assert converted.tool_protocol == "native" + request = converted.request + assert request.request_id == "request" and request.config_revision == openai_profile.config_revision + assert [m.content_parts[0]["text"] for m in request.messages] == [ + "platform", "skill", "knowledge", "progress", "result", "approved metadata", "continue", + ] + assert request.messages[3].tool_calls[0].provider_call_id == request.messages[4].tool_call_id == "call-1" + assert request.tools == view.tool_definitions + wire = OpenAIChatAdapter().prepare_http_request(request, openai_profile) + assert [message["role"] for message in wire.json_body["messages"]] == [ + "system", "system", "user", "assistant", "tool", "user", "user", + ] + assert view.model_dump() == before + view.history[0].message.content_parts[0]["text"] = "changed after conversion" + assert request.messages[3].content_parts[0]["text"] == "progress" + + +@pytest.mark.parametrize("status", ["supported", "unsupported", "unknown"]) +@pytest.mark.parametrize("native", [False, True]) +def test_text_protocol_is_selected_before_request_without_changing_resources(openai_profile, status, native): + data = openai_profile.model_dump() + data["capabilities"]["tools"]["status"] = status + profile = openai_profile.__class__.model_validate(data) + view = resources() + before = view.model_dump() + converted = ResourceRequestConverter(native=native).convert(view, profile) + actual_native = native and status == "supported" + assert converted.tool_protocol == ("native" if actual_native else "text_block") + request = converted.request + assert bool(request.tools) == actual_native + wire = OpenAIChatAdapter().prepare_http_request(request, profile) + if not actual_native: + assert all(not message.tool_calls and message.tool_call_id is None for message in request.messages) + assert all(message["role"] != "tool" for message in wire.json_body["messages"]) + blocks = [json.loads(part["text"]) for message in request.messages for part in message.content_parts + if part.get("type") == "text" and part["text"].startswith("{")] + assert {"historical_tool_calls": [{"id": "call-1", "name": "lookup", "arguments": '{"q":"x"}'}]} in blocks + assert {"historical_tool_result": "call-1"} in blocks + assert {"available_tools": [tool.model_dump() for tool in view.tool_definitions]} in blocks + assert view.model_dump() == before + + +def test_resource_boundary_rejects_privilege_promotion_and_execution_objects(): + data = resources().model_dump() + data["references"][0]["message"]["semantic_role"] = "platform_instruction" + with pytest.raises(ValidationError, match="authorized group"): + RuntimeResourceView.model_validate(data) + data = resources().model_dump() + data["executor"] = object() + with pytest.raises(ValidationError): + RuntimeResourceView.model_validate(data) + data = resources().model_dump() + data["current_user_input"]["order"] = data["history"][0]["order"] + with pytest.raises(ValidationError, match="ordering"): + RuntimeResourceView.model_validate(data) + + +def test_converter_registry_rejects_unknown_versions(): + registry = ResourceConverterRegistry() + for mode in ("code_converter", "native_converter"): + reference, = registry.references(mode) + assert registry.resolve(reference).reference == reference + with pytest.raises(ValueError, match="UNREGISTERED"): + registry.resolve(reference.model_copy(update={"version": 2})) + + +def test_code_observations_remain_usable_in_native_history(openai_profile): + view = resources() + values = view.model_dump() + values["history"][0]["message"]["tool_calls"] = [] + values["tool_observations"][0]["message"]["tool_call_id"] = None + converted = ResourceRequestConverter(native=True).convert(RuntimeResourceView.model_validate(values), openai_profile) + wire = OpenAIChatAdapter().prepare_http_request(converted.request, openai_profile) + assert wire.json_body["messages"][4]["role"] == "user" + assert converted.request.messages[4].content_parts[-1]["text"] == "result" + assert values["tool_observations"][0]["message"]["semantic_role"] == "tool_result" + + +def test_code_conversion_does_not_replay_provider_reasoning_or_mutate_history(openai_profile): + view = resources() + view.history[0].message.reasoning_parts.append({"type": "text", "text": "provider reasoning"}) + result = ResourceRequestConverter().convert(view, openai_profile) + assert not any(message.reasoning_parts for message in result.request.messages) + assert view.history[0].message.reasoning_parts == [{"type": "text", "text": "provider reasoning"}] diff --git a/test/sdk/core/models/test_openai_llm_timeout_compat.py b/test/sdk/core/models/test_openai_llm_timeout_compat.py new file mode 100644 index 0000000000..9d92bc8687 --- /dev/null +++ b/test/sdk/core/models/test_openai_llm_timeout_compat.py @@ -0,0 +1,58 @@ +import types + +import httpx + +from nexent.core.models.openai_llm import _resolve_openai_timeout_class + + +class CompatibleTimeout: + """Minimal stand-in for the Timeout type owned by a patched OpenAI SDK.""" + + def __init__(self, *, connect, read, write, pool): + self.connect = connect + self.read = read + self.write = write + self.pool = pool + + +def test_timeout_class_uses_openai_httpx2_backend_when_present(): + """A patched OpenAI client must receive its own httpx2 Timeout type.""" + base_client = types.SimpleNamespace( + httpx2=types.SimpleNamespace(Timeout=CompatibleTimeout), + httpx=types.SimpleNamespace(Timeout=httpx.Timeout), + ) + + assert _resolve_openai_timeout_class(base_client) is CompatibleTimeout + + +def test_timeout_class_uses_openai_httpx_backend_when_present(): + """The official OpenAI client must continue using its httpx Timeout type.""" + base_client = types.SimpleNamespace( + httpx=types.SimpleNamespace(Timeout=httpx.Timeout), + ) + + assert _resolve_openai_timeout_class(base_client) is httpx.Timeout + + +def test_timeout_class_falls_back_to_standard_httpx(): + """Unknown OpenAI builds retain the standard httpx behavior.""" + assert _resolve_openai_timeout_class(types.SimpleNamespace()) is httpx.Timeout + + +def test_timeout_resolution_preserves_phase_specific_values(): + """Backend compatibility must not collapse the four timeout phases.""" + base_client = types.SimpleNamespace( + httpx2=types.SimpleNamespace(Timeout=CompatibleTimeout), + ) + timeout = _resolve_openai_timeout_class(base_client)( + connect=11, + read=45, + write=31, + pool=12, + ) + + assert isinstance(timeout, CompatibleTimeout) + assert timeout.connect == 11 + assert timeout.read == 45 + assert timeout.write == 31 + assert timeout.pool == 12 diff --git a/test/sdk/monitor/test_monitoring_token_totals.py b/test/sdk/monitor/test_monitoring_token_totals.py new file mode 100644 index 0000000000..3b5feea0b9 --- /dev/null +++ b/test/sdk/monitor/test_monitoring_token_totals.py @@ -0,0 +1,26 @@ +"""Monitor compatibility with canonical ModelService usage totals.""" +import json +from unittest.mock import Mock + +import pytest +from nexent.monitor.monitoring import LLMTokenTracker + + +@pytest.mark.parametrize("total", [0, 99]) +def test_token_tracker_accepts_reported_total(total): + manager = Mock(is_enabled=True) + span = Mock() + tracker = LLMTokenTracker(manager, "fixture", span) + tracker.record_completion(2, 3, total_tokens=total) + attrs = span.set_attributes.call_args.args[0] + assert attrs["llm.token_count.total"] == total + assert json.loads(attrs["llm.usage_details"])["total"] == total + + +@pytest.mark.parametrize("total", [-1, True, "5"]) +def test_token_tracker_rejects_invalid_reported_total(total): + manager = Mock(is_enabled=True) + tracker = LLMTokenTracker(manager, "fixture", Mock()) + with pytest.raises(ValueError): + tracker.record_completion(2, 3, total_tokens=total) + manager.record_llm_metrics.assert_not_called() From 193fc901210bfecb703ef7c873abe9a3b78bced2 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Tue, 20 Oct 2026 16:36:30 +0800 Subject: [PATCH 09/26] fix: harden Jiuwen model and output adapters --- .../conversation_management_service.py | 6 ++ .../services/model_runtime_options_service.py | 6 ++ backend/utils/runtime_output.py | 62 ++++++++++++ .../core/agent_runtime_support/answer.py | 95 ++++++++++++++++++ .../core/agents/core_adapters/events.py | 33 +++++-- .../core/agents/core_adapters/jiuwen.py | 4 + sdk/nexent/core/agents/core_agent.py | 26 +++++ .../model_service/adapters/openai_stream.py | 9 +- .../core/prompts/en/agent/agent_manager.yaml | 3 +- .../core/prompts/en/agent/agent_worker.yaml | 3 +- .../core/prompts/zh/agent/agent_manager.yaml | 3 +- .../core/prompts/zh/agent/agent_worker.yaml | 3 +- .../JIUWEN-MODEL-BRIDGE-D3-001/case.yaml | 6 +- .../test_runtime.py | 10 +- test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml | 6 +- .../cases/JIUWEN-OUTPUT-D3-001/test_output.py | 13 ++- .../cases/JIUWEN-REAL-UI-D4-001/case.yaml | 19 ++++ .../JIUWEN-REAL-UI-D4-001/execution.yaml | 11 ++- .../cases/JIUWEN-REAL-UI-D4-001/fixture.py | 32 +++++++ .../cases/JIUWEN-REAL-UI-D4-001/test.spec.ts | 34 ++++++- .../changes/bugs/JIUWEN-ANSWER-20261020.yaml | 49 ++++++++++ .../bugs/JIUWEN-AUTO-EFFORT-20261019.yaml | 42 ++++++++ .../bugs/JIUWEN-PROTOCOL-20261020.yaml | 54 +++++++++++ .../test-fixes/JIUWEN-REAL-UI-20261019.yaml | 13 +++ .../infra/automation/jiuwen_bridge_fixture.py | 9 ++ .../test_conversation_management_service.py | 25 +++++ ...test_model_runtime_provisioning_service.py | 31 ++++++ .../utils/test_runtime_history_projection.py | 51 ++++++++++ .../agents/test_jiuwen_answer_boundary.py | 96 +++++++++++++++++++ .../agents/test_legacy_action_feedback.py | 63 ++++++++++++ .../test_qwen_stream_placeholders.py | 53 ++++++++++ 31 files changed, 839 insertions(+), 31 deletions(-) create mode 100644 sdk/nexent/core/agent_runtime_support/answer.py create mode 100644 test-e2e/changes/bugs/JIUWEN-ANSWER-20261020.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-AUTO-EFFORT-20261019.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-PROTOCOL-20261020.yaml create mode 100644 test/backend/utils/test_runtime_history_projection.py create mode 100644 test/sdk/core/agents/test_jiuwen_answer_boundary.py create mode 100644 test/sdk/core/agents/test_legacy_action_feedback.py create mode 100644 test/sdk/core/model_service/test_qwen_stream_placeholders.py diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 414172333e..7bbf1da2d2 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -57,6 +57,7 @@ from utils.config_utils import tenant_config_manager from nexent.core.agents.prompt.auxiliary import compose_auxiliary_prompt from utils.str_utils import remove_think_blocks +from utils.runtime_output import project_runtime_history logger = logging.getLogger("conversation_management_service") monitoring_manager = get_monitoring_manager() @@ -881,6 +882,9 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List message_item['minio_files'] = msg['minio_files'] else: # Assistant message: message is an array, need to process search_content_placeholder + message_content, message_units, invalid_runtime_answer = project_runtime_history( + message_content, message_units + ) processed_units = [] for unit in message_units: unit_id = unit.get('unit_id') @@ -964,6 +968,8 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List message_item['minio_files'] = msg['minio_files'] message_item["status"] = msg.get("status", "completed") + if role != MESSAGE_ROLE["USER"] and invalid_runtime_answer: + message_item["status"] = "failed" # Keep the logical message position so clients can distinguish a # regenerated branch from a separate turn with identical text. diff --git a/backend/services/model_runtime_options_service.py b/backend/services/model_runtime_options_service.py index d6c88376f6..1da55cc398 100644 --- a/backend/services/model_runtime_options_service.py +++ b/backend/services/model_runtime_options_service.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Compile legacy request options against a detached saved outlet.""" from copy import deepcopy @@ -25,6 +27,10 @@ def compile_model_runtime_profile(model_config, agent_config): # The legacy boolean default alone is not an explicit provider override. continue if key not in rules: + if key == "reasoning_effort" and value == "auto": + # Let the shared resolver interpret the legacy omission sentinel. + submitted[key] = value + continue if value is not None and (key not in {"enable_thinking"} or value is True): raise ValueError("MODEL_OUTLET_OVERRIDE_UNDECLARED") continue diff --git a/backend/utils/runtime_output.py b/backend/utils/runtime_output.py index 91b7fd6944..ca7a214294 100644 --- a/backend/utils/runtime_output.py +++ b/backend/utils/runtime_output.py @@ -1,5 +1,13 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Validate the explicit root result without inferring success from stream EOF.""" +import json + +from nexent.core.agent_runtime_support.answer import ( + AnswerBoundaryError, + PublicTextFilter, + normalize_public_answer, +) from nexent.core.agent_runtime_support.output import RunResult @@ -8,6 +16,60 @@ def is_runtime_event(event): and event.get("agent_core") == "jiuwen") +def project_runtime_history(content, units): + """Clean proven runtime records for display without rewriting audit data or user messages.""" + parsed = [] + for unit in units: + try: + event = json.loads(unit.get("unit_content", "")) + except (ValueError, TypeError): + event = None + parsed.append(event if isinstance(event, dict) and is_runtime_event(event) else None) + if not any(parsed): + return content, units, False + native_invocations = {e.get("invocation_id") for e in parsed if e} + filters = {} + projected = [] + invalid_root = False + + def final(text): + try: + return normalize_public_answer(text), False + except AnswerBoundaryError: + return "", True + + for unit, event in zip(units, parsed): + item = dict(unit) + if event: + event = dict(event) + kind = event.get("type") + if kind in {"model_output", "model_output_deep_thinking"}: + key = (event.get("turn_id"), event.get("invocation_id"), event.get("block_id"), kind) + event["content"] = filters.setdefault(key, PublicTextFilter()).push(event.get("content", "")) + elif kind == "run_terminal" and event.get("status") == "completed": + event["final_content"], invalid = final(event.get("final_content", "")) + if invalid: + event.update(status="failed", final_block_ids=[], end_reason="JIUWEN_FINAL_OUTPUT_INVALID", + error={"category": "JIUWEN_FINAL_OUTPUT_INVALID", "scope": "protocol", + "retryable": False, "safe_message": "JIUWEN_FINAL_OUTPUT_INVALID"}) + invalid_root |= not event.get("invocation_id") + # Persisted terminal content also carries a serialized RunResult. + # Keep both representations consistent for all existing consumers. + if "content" in event: + event["content"] = json.dumps({key: event[key] for key in ( + "turn_id", "status", "end_reason", "final_content", "final_block_ids", "error" + ) if key in event}, ensure_ascii=False) + item["unit_content"] = json.dumps(event, ensure_ascii=False) + elif item.get("unit_type") == "final_answer" and item.get("invocation_id") in native_invocations: + item["unit_content"], invalid = final(item.get("unit_content", "")) + invalid_root |= invalid and not item.get("invocation_id") + projected.append(item) + if None in native_invocations: + content, invalid = final(content) if content else (content, False) + invalid_root |= invalid + return content, projected, invalid_root + + class RuntimeOutputState: def __init__(self, required=False): self.required = required diff --git a/sdk/nexent/core/agent_runtime_support/answer.py b/sdk/nexent/core/agent_runtime_support/answer.py new file mode 100644 index 0000000000..561fa8544b --- /dev/null +++ b/sdk/nexent/core/agent_runtime_support/answer.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Public answer projection for reserved framework context and final envelopes.""" + +import re + + +HISTORY_TAGS = {"completed_action_history", "completed_action", "historical_attachments"} +_TAG = re.compile(r"<\s*(/?)\s*([a-z_]+)\b[^>]*>", re.IGNORECASE) +_FINAL = re.compile(r"(.*?)", re.IGNORECASE | re.DOTALL) + + +class AnswerBoundaryError(ValueError): + """Reject an ambiguous answer without exposing its source or executing actions.""" + + +class PublicTextFilter: + """Withhold split control prefixes and discard internal block bodies incrementally.""" + + def __init__(self, *, preserve_final=False): + self.pending = "" + self.hidden = [] + self.invalid = False + self.preserve_final = preserve_final + + def push(self, text): + self.pending += text + output = [] + names = HISTORY_TAGS | {"final_answer"} + while self.pending: + offset = self.pending.find("<") + if offset < 0: + if not self.hidden: + output.append(self.pending) + self.pending = "" + break + if offset: + if not self.hidden: + output.append(self.pending[:offset]) + self.pending = self.pending[offset:] + match = _TAG.match(self.pending) + if match: + closing, name = match.group(1), match.group(2).lower() + tag = match.group() + self.pending = self.pending[match.end():] + if name in HISTORY_TAGS: + if closing: + if not self.hidden or self.hidden[-1] != name: + self.invalid = True + else: + self.hidden.pop() + else: + self.hidden.append(name) + elif not self.hidden and (name != "final_answer" or self.preserve_final): + output.append(tag) + continue + prefix = re.match(r"<\s*/?\s*([a-z_]*)", self.pending, re.IGNORECASE) + if prefix and ( + re.fullmatch(r"<\s*/?\s*", self.pending) + or prefix.group(1) and any(name.startswith(prefix.group(1).lower()) for name in names) + ): + # A bounded malformed tag is never flushed into the public stream. + if len(self.pending) > 4096: + self.invalid = True + self.pending = "" + self.hidden.append("invalid") + break + if not self.hidden: + output.append("<") + self.pending = self.pending[1:] + return "".join(output) + + def finish(self): + if self.pending or self.hidden or self.invalid: + raise AnswerBoundaryError("JIUWEN_FINAL_OUTPUT_INVALID") + + +def normalize_public_answer(text): + """Extract one unambiguous final body, preserving ordinary Markdown and file links.""" + if not isinstance(text, str): + raise AnswerBoundaryError("JIUWEN_FINAL_OUTPUT_INVALID") + history = PublicTextFilter(preserve_final=True) + clean = history.push(text) + history.finish() + finals = list(_FINAL.finditer(clean)) + markers = re.findall(r"<\s*/?\s*final_answer\b[^>]*>", clean, re.IGNORECASE) + if markers: + if len(finals) != 1 or len(markers) != 2 or _FINAL.fullmatch(clean.strip()) is None: + raise AnswerBoundaryError("JIUWEN_FINAL_OUTPUT_INVALID") + clean = finals[0].group(1) + if re.search(r"<\s*/?\s*(?:code|final_answer)\b|```", clean, re.IGNORECASE): + raise AnswerBoundaryError("JIUWEN_FINAL_OUTPUT_INVALID") + clean = clean.strip() + if not clean: + raise AnswerBoundaryError("JIUWEN_FINAL_OUTPUT_INVALID") + return clean diff --git a/sdk/nexent/core/agents/core_adapters/events.py b/sdk/nexent/core/agents/core_adapters/events.py index d48dd73b7f..cfe6510dd8 100644 --- a/sdk/nexent/core/agents/core_adapters/events.py +++ b/sdk/nexent/core/agents/core_adapters/events.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Translate Jiuwen facts to public observations and one explicit run result.""" import json @@ -5,6 +6,7 @@ import time import uuid +from ...agent_runtime_support.answer import AnswerBoundaryError, PublicTextFilter, normalize_public_answer from ...agent_runtime_support.output import RunResult, StepObservation from ...model_service.contracts import ModelError from ...utils.observer import ProcessType @@ -42,7 +44,16 @@ def begin_step(self, request): raise RuntimeError("Model step after Runner terminal") self.step_number += 1 self.output(ProcessType.STEP_COUNT, str(self.step_number), step_number=self.step_number) - self.blocks.append({"id": uuid.uuid4().hex, "text": "", "reasoning": "", "started": time.monotonic()}) + self.blocks.append({"id": uuid.uuid4().hex, "text": "", "reasoning": "", "started": time.monotonic(), + "filters": {"text": PublicTextFilter(), "reasoning": PublicTextFilter()}, + "received": set()}) + + def _model_text(self, block, key, text, kind): + block["received"].add(key) + visible = block["filters"][key].push(text) + block[key] += visible + if visible: + self.output(kind, visible, block_id=block["id"], phase="partial") def model_event(self, event): if event.type == "error": @@ -51,17 +62,15 @@ def model_event(self, event): block = self.blocks[-1] if event.type in {"text_delta", "reasoning_delta"}: reasoning = event.type == "reasoning_delta" - block["reasoning" if reasoning else "text"] += event.delta - self.output(ProcessType.MODEL_OUTPUT_DEEP_THINKING if reasoning else ProcessType.MODEL_OUTPUT, - event.delta, block_id=block["id"], phase="partial") + self._model_text(block, "reasoning" if reasoning else "text", event.delta, + ProcessType.MODEL_OUTPUT_DEEP_THINKING if reasoning else ProcessType.MODEL_OUTPUT) elif event.type == "message_completed": message = event.message for key, parts, kind in (("reasoning", message.reasoning_parts, ProcessType.MODEL_OUTPUT_DEEP_THINKING), ("text", message.content_parts, ProcessType.MODEL_OUTPUT)): text = "".join(part.get("text", "") for part in parts if part.get("type") == "text") - if not block[key] and text: - block[key] = text - self.output(kind, text, block_id=block["id"], phase="partial") + if key not in block["received"] and text: + self._model_text(block, key, text, kind) block["observed"] = True self.calls.extend(message.tool_calls) observation = StepObservation(turn_id=self.turn_id, block_id=block["id"], @@ -117,7 +126,11 @@ def emit(self, chunk): output = payload.get("output") if not isinstance(output, str) or not output.strip(): raise RuntimeError("Jiuwen returned no final answer") - self.final_answer = output + try: + self.final_answer = normalize_public_answer(output) + except AnswerBoundaryError: + self.last_error = self.error("JIUWEN_FINAL_OUTPUT_INVALID") + raise elif kind == "context.usage" and isinstance(payload, dict): self.observer.add_message(self.agent_name, ProcessType.TOKEN_COUNT, json.dumps(payload)) # ModelService owns ordered content/reasoning; native llm_output would duplicate it. @@ -130,7 +143,7 @@ def finish(self, status="completed", error=None): if self.result is not None: return self.result error = error or self.last_error - if status == "completed" and self.final_answer is None: + if status == "completed" and self.final_answer is None and error is None: error = self.error("JIUWEN_TERMINAL_MISSING") if error is not None and status == "completed": status = "failed" @@ -138,7 +151,7 @@ def finish(self, status="completed", error=None): error = self.error("JIUWEN_EXECUTION_FAILED") final_ids = [] if status == "completed": - matching = next((b for b in reversed(self.blocks) if b["text"] == self.final_answer), None) + matching = next((b for b in reversed(self.blocks) if b["text"].strip() == self.final_answer), None) if matching is None: matching = {"id": uuid.uuid4().hex, "text": self.final_answer} self.blocks.append(matching) diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/core_adapters/jiuwen.py index a43cdfce18..17a2ac1d7a 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen.py +++ b/sdk/nexent/core/agents/core_adapters/jiuwen.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Compile Nexent resources into a native Jiuwen agent and run its Runner.""" import asyncio @@ -353,6 +355,8 @@ def _compile_context(self): "results are historical reference, not pending tasks. Do not repeat earlier requests or tool calls " "unless the latest user request explicitly asks you to. Reuse prior results when answering follow-up " "questions; the presence of an earlier attachment does not require analyzing it again." + " Never copy internal history or attachment envelopes into your response. " + "Return only the user-facing answer as ordinary Markdown, without final_answer protocol tags." ) return prompt + "\n\n" + current_task_guidance diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index d513bc74ad..7fb7eef4b2 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import ast import json import logging @@ -1003,6 +1005,19 @@ def _legacy_parse_fallback( """Keep the pre-whitelist CodeAgent response and ordinary step-error flow.""" self._resolve_deferred_model_attempt(memory_step.model_output_message, accepted=True) + outside_final = re.sub( + r".*?", "", str(model_output), flags=re.IGNORECASE | re.DOTALL + ) + if re.search(r"", str(model_output), re.IGNORECASE) and re.search( + r"|```", outside_final, re.IGNORECASE + ): + raise AgentExecutionError( + "Do not combine executable code and in one response. " + "Return only the action and wait for its observation, or return only " + "... when existing observations prove completion. " + "No code from this mixed response was executed.", + self.logger, + ) if _looks_like_incomplete_legacy_action( model_output, available_tool_names=self._known_tool_names(), @@ -1481,6 +1496,17 @@ def rebuild_after_provider_overflow(): except ModelOutputProtocolError as protocol_error: if not legacy_code_action: raise + try: + ast.parse(code_action) + except SyntaxError as syntax_error: + raise AgentExecutionError( + f"Invalid Python action at line {syntax_error.lineno}: {syntax_error.msg}. " + "Return executable Python inside .... For an allowed " + "dependency installation, use import sys, subprocess; " + 'subprocess.run([sys.executable, "-m", "pip", "install", "package"], check=True). ' + "Do not write shell command syntax as Python. Sandbox restrictions still apply.", + self.logger, + ) from protocol_error raise AgentExecutionError( "Legacy code action could not be parsed for optional clarification: " f"{protocol_error.reason.value}", diff --git a/sdk/nexent/core/model_service/adapters/openai_stream.py b/sdk/nexent/core/model_service/adapters/openai_stream.py index 537c25e2e8..e4d14988a5 100644 --- a/sdk/nexent/core/model_service/adapters/openai_stream.py +++ b/sdk/nexent/core/model_service/adapters/openai_stream.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """One-message OpenAI-compatible stream state, independent of HTTP execution.""" import json @@ -199,8 +201,11 @@ def _call_delta(self, raw): state = self._calls[index] if "type" in raw and raw["type"] != "function": self._fail("MODEL_TOOL_CALL_INVALID") - if raw.get("id") is not None: - value = raw["id"] + value = raw.get("id") + # Some providers repeat an empty placeholder after establishing the call identity. + if value == "" and state["id"] is not None: + value = None + if value is not None: if not isinstance(value, str) or not value.strip() or state["id"] is not None and state["id"] != value: self._fail("MODEL_STREAM_IDENTITY_CONFLICT") state["id"] = value diff --git a/sdk/nexent/core/prompts/en/agent/agent_manager.yaml b/sdk/nexent/core/prompts/en/agent/agent_manager.yaml index 4b794e53b3..34b13f8e67 100644 --- a/sdk/nexent/core/prompts/en/agent/agent_manager.yaml +++ b/sdk/nexent/core/prompts/en/agent/agent_manager.yaml @@ -13,7 +13,7 @@ system_sections: ### Sandbox workspace Read the concrete run workspace path from `Run workspace:` in the user message. Uploaded inputs are under its `inputs` directory; generated files belong in its `outputs` directory. The code executor's current directory is this run's `outputs` directory. Create files with bare relative paths such as `report.pdf`; do not use `outputs/report.pdf`. - Do not use `subprocess`, `os.system`, or a shell for system commands. Only when sandbox networking is enabled and a Python dependency is needed may you use a shell-free `sys.executable -m pip install` argv call. Use available tools or skill scripts for other system capabilities. + Do not execute system commands through subprocess, os.system, or a shell. The sole dependency-installation exception, when sandbox networking is enabled and installation is needed, is valid shell-free Python: `import sys, subprocess; subprocess.run([sys.executable, "-m", "pip", "install", "python-docx"], check=True)`. Never write shell command syntax as Python. Use tools or skill scripts for other system capabilities. skill_usage: |- ### Skill Usage Requirements Match the request against descriptions in ``. For a match, first call `read_skill_md(skill_name="skill-name", additional_files=[])`, then follow the guide. @@ -41,6 +41,7 @@ system_sections: final_answer_guidance: |- ### Final Answer Requirements + Executable actions and final answers must be separate responses. After emitting code, wait for its observation. Never combine a code action and a envelope, print a redundant completion action, or claim file generation/upload succeeded before the tool observation confirms it. After thinking, when you are ready to answer, return exactly one `...` envelope with the complete user-facing answer inside it. Do not wrap the envelope in ``, put content outside it, or perform another action afterward. Follow the user's requested response format strictly; do not add headings, paragraphs, or fields the user did not request. diff --git a/sdk/nexent/core/prompts/en/agent/agent_worker.yaml b/sdk/nexent/core/prompts/en/agent/agent_worker.yaml index 405b57711b..50e6c0bd63 100644 --- a/sdk/nexent/core/prompts/en/agent/agent_worker.yaml +++ b/sdk/nexent/core/prompts/en/agent/agent_worker.yaml @@ -13,7 +13,7 @@ system_sections: ### Sandbox workspace Read the concrete run workspace path from `Run workspace:` in the user message. Uploaded inputs are under its `inputs` directory; generated files belong in its `outputs` directory. The code executor's current directory is this run's `outputs` directory. Create files with bare relative paths such as `report.pdf`; do not use `outputs/report.pdf`. - Do not use `subprocess`, `os.system`, or a shell for system commands. Only when sandbox networking is enabled and a Python dependency is needed may you use a shell-free `sys.executable -m pip install` argv call. Use available tools or skill scripts for other system capabilities. + Do not execute system commands through subprocess, os.system, or a shell. The sole dependency-installation exception, when sandbox networking is enabled and installation is needed, is valid shell-free Python: `import sys, subprocess; subprocess.run([sys.executable, "-m", "pip", "install", "python-docx"], check=True)`. Never write shell command syntax as Python. Use tools or skill scripts for other system capabilities. skill_usage: |- ### Skill Usage Requirements Match the request against descriptions in ``. For a match, first call `read_skill_md(skill_name="skill-name", additional_files=[])`, then follow the guide. @@ -41,6 +41,7 @@ system_sections: final_answer_guidance: |- ### Final Answer Requirements + Executable actions and final answers must be separate responses. After emitting code, wait for its observation. Never combine a code action and a envelope, print a redundant completion action, or claim file generation/upload succeeded before the tool observation confirms it. After thinking, when you are ready to answer, return exactly one `...` envelope with the complete user-facing answer inside it. Do not wrap the envelope in ``, put content outside it, or perform another action afterward. Follow the user's requested response format strictly; do not add headings, paragraphs, or fields the user did not request. diff --git a/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml b/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml index 4c6c09b3ee..0f3deaedf0 100644 --- a/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml +++ b/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml @@ -13,7 +13,7 @@ system_sections: ### 沙箱 workspace 从用户消息中的 `Run workspace:` 读取本次运行的实际工作目录。上传文件位于该目录下的 `inputs`,生成文件写入该目录下的 `outputs`。 代码执行器的当前目录是本次运行的 `outputs` 目录。生成文件时使用裸相对路径,例如 `report.pdf`,不要写成 `outputs/report.pdf`。 - 禁止使用 `subprocess`、`os.system` 或 Shell 执行系统命令。仅在沙箱允许联网且需要安装 Python 依赖时,可以使用不经过 Shell 的 `sys.executable -m pip install` argv 调用;其他系统能力必须通过可用工具或技能脚本完成。 + 禁止通过 subprocess、os.system 或 Shell 执行系统命令。唯一的依赖安装例外:仅在沙箱允许联网且确实需要安装依赖时,使用合法且不经过 Shell 的 Python:`import sys, subprocess; subprocess.run([sys.executable, "-m", "pip", "install", "python-docx"], check=True)`。不要把 Shell 命令语法当作 Python 代码。其他系统能力必须通过可用工具或技能脚本完成。 skill_usage: |- ### 技能使用要求 先根据 `` 中的 description 判断是否匹配。匹配时先调用 `read_skill_md(skill_name="技能名", additional_files=[])`,再按指南执行。 @@ -42,6 +42,7 @@ system_sections: final_answer_guidance: |- ### 最终回答要求 + 可执行动作和最终回答必须分开输出。输出代码后等待执行观察结果。禁止在同一响应中同时输出代码动作和 ,禁止为宣布完成再执行多余的 print,禁止在工具观察结果确认前宣称文件已生成或上传。 思考结束后,当你认为可以回答用户问题,必须输出唯一一对 `...`,并将完整的用户可见回答放在标签内。不得在标签外输出内容,也不得继续执行动作。 用户明确指定响应格式时,应严格按该格式回答;不擅自增加用户未要求的标题、段落或字段。 diff --git a/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml b/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml index aad8d4b3f0..96ce524ea9 100644 --- a/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml +++ b/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml @@ -13,7 +13,7 @@ system_sections: ### 沙箱 workspace 从用户消息中的 `Run workspace:` 读取本次运行的实际工作目录。上传文件位于该目录下的 `inputs`,生成文件写入该目录下的 `outputs`。 代码执行器的当前目录是本次运行的 `outputs` 目录。生成文件时使用裸相对路径,例如 `report.pdf`,不要写成 `outputs/report.pdf`。 - 禁止使用 `subprocess`、`os.system` 或 Shell 执行系统命令。仅在沙箱允许联网且需要安装 Python 依赖时,可以使用不经过 Shell 的 `sys.executable -m pip install` argv 调用;其他系统能力必须通过可用工具或技能脚本完成。 + 禁止通过 subprocess、os.system 或 Shell 执行系统命令。唯一的依赖安装例外:仅在沙箱允许联网且确实需要安装依赖时,使用合法且不经过 Shell 的 Python:`import sys, subprocess; subprocess.run([sys.executable, "-m", "pip", "install", "python-docx"], check=True)`。不要把 Shell 命令语法当作 Python 代码。其他系统能力必须通过可用工具或技能脚本完成。 skill_usage: |- ### 技能使用要求 先根据 `` 中的 description 判断是否匹配。匹配时先调用 `read_skill_md(skill_name="技能名", additional_files=[])`,再按指南执行。 @@ -41,6 +41,7 @@ system_sections: final_answer_guidance: |- ### 最终回答要求 + 可执行动作和最终回答必须分开输出。输出代码后等待执行观察结果。禁止在同一响应中同时输出代码动作和 ,禁止为宣布完成再执行多余的 print,禁止在工具观察结果确认前宣称文件已生成或上传。 思考结束后,当你认为可以回答用户问题,必须输出唯一一对 `...`,将完整的用户可见回答放在标签内。不得在标签外输出内容,也不得继续执行动作。 用户明确指定响应格式时,应严格按该格式回答;不擅自增加用户未要求的标题、段落或字段。 diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml index c63ab6d783..65a5c2a3ad 100644 --- a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml @@ -32,13 +32,15 @@ case: action: Run the actual Jiuwen adapter with a trusted snapshot and the selected protocol. expected: Text requests contain no native tools; actual tool observations reach - the next request; native provider IDs are preserved. + the next request; native provider IDs are preserved, including an empty-ID + continuation placeholder after the same call index is already bound. expected_results: - The actual factory/adapter/Jiuwen loop uses canonical ModelService and real local HTTP/SSE; native and text/unknown capability paths return one final result and one tool effect. - Text requests contain no native tools; actual tool observations reach the next - request; native provider IDs are preserved. + request; native provider IDs are preserved, including an empty-ID continuation + placeholder after the same call index is already bound. forbidden_side_effects: - No native Jiuwen provider HTTP or CodeAgent loop; no unauthorized executable bindings, credentials or duplicate effects. diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py index 57b56c05b2..41661aca82 100644 --- a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Canonical ModelService through the real Nexent Jiuwen worker.""" import asyncio @@ -21,9 +23,11 @@ def cleanup(): stop_infrastructure() -@pytest.mark.parametrize("protocol", ["native", "text", "unknown"]) -def test_factory_service_protocol_loop(monkeypatch, protocol): - info, state, output = asyncio.run(run_worker(monkeypatch, protocol)) +@pytest.mark.parametrize("protocol,scenario", [ + ("native", "normal"), ("native", "empty_call_id"), ("text", "normal"), ("unknown", "normal") +]) +def test_factory_service_protocol_loop(monkeypatch, protocol, scenario): + info, state, output = asyncio.run(run_worker(monkeypatch, protocol, scenario)) assert info.attempt_outcome == "completed", output[-1:] assert state["tool_calls"] == [(2, 3)] assert len(state["requests"]) == 2 diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml index 39cb6ff465..a9b72cd965 100644 --- a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml +++ b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml @@ -35,7 +35,9 @@ case: saved conversation. expected: Exactly one explicit RunResult determines status and final content; JSON envelopes and original results survive storage, with final blocks unambiguously - linked. + linked. Internal history echoes and final envelope tags are absent from public + deltas, terminal content and reloaded answers; exact structured tool results + remain unchanged. expected_results: - Observations retain ordered content/reasoning blocks, exact tool results, full provider IDs, unique local IDs and invocation grouping; usage absent is unknown. @@ -43,6 +45,8 @@ case: configuration and survive persistence. - Exactly one explicit RunResult determines status and final content; JSON envelopes and original results survive storage, with final blocks unambiguously linked. + Internal context and final protocol envelopes cannot leak into public answers + or saved display. forbidden_side_effects: - No inferred success from EOF or text; no duplicate final, lost partial, cross-invocation tool pairing, invented reasoning or native HTTP bypass. diff --git a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py index bafa5dcb84..6ddeb7955f 100644 --- a/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py +++ b/test-e2e/cases/JIUWEN-OUTPUT-D3-001/test_output.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import asyncio import json import sys @@ -19,7 +20,7 @@ def cleanup(): pytestmark=[pytest.mark.case_id("JIUWEN-OUTPUT-D3-001"),pytest.mark.stage("D3")] @pytest.mark.parametrize("protocol",["native","text"]) -@pytest.mark.parametrize("resource",["chain","jiuwen","smolagents","a2a"]) +@pytest.mark.parametrize("resource",["chain","jiuwen","smolagents","a2a","echo"]) def test_runtime_output_roundtrip(monkeypatch,tmp_path,protocol,resource): with context_database(monkeypatch): if resource=="chain": @@ -27,6 +28,13 @@ def test_runtime_output_roundtrip(monkeypatch,tmp_path,protocol,resource): info,state,output,calls,effects,_=asyncio.run(run_resources(monkeypatch,tmp_path,url,protocol)) assert calls==[(2,3)] and state["mcp_calls"]==1 and effects=="x" assert state["saw_skill"] + elif resource == "echo": + info,state,output=asyncio.run(run_worker(monkeypatch,protocol,"echo_history")) + assert state["tool_calls"] == [(2,3)] + assert len(state["requests"]) == 2 + assert info.run_result.final_content == "SPIKE_FINAL_RESULT_5" + assert "MODEL_PRIVATE_HISTORY" not in json.dumps(output) + assert "" not in json.dumps(output) else: info,state,output,_=asyncio.run(run_collaboration(monkeypatch,"jiuwen",resource,protocol,"JSONRPC")) assert state["auth_ok"] and all(state["auth_ok"]) @@ -68,3 +76,6 @@ def test_runtime_output_roundtrip(monkeypatch,tmp_path,protocol,resource): terminal=next(e for e in saved if e["type"]=="run_terminal" and not e.get("invocation_id")) assert terminal["final_block_ids"] assert "fixture-only" not in json.dumps(saved) + assert "MODEL_PRIVATE_HISTORY" not in json.dumps(saved) + assert "" not in assistant["message_content"] diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml index 7bb89a44f5..fffcb7d956 100644 --- a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/case.yaml @@ -52,6 +52,13 @@ case: use the same percentage/detail component, with identical model capacity and configured CM threshold; CM off retains capacity and CM on retains adaptive mode. + - order: 4 + action: Switch the owned assistant back to Jiuwen, request a docx through its + configured file-generation child, follow up and reload history. + expected: The actual child generates and uploads a docx containing the random + marker; downloaded document content verifies, follow-up does not replay tools, + live and reloaded final answers contain no internal history or final protocol + tags. expected_results: - Real browser uploads and both Core image analyses use configured real models and return known visual facts, with completed persisted turns. @@ -59,12 +66,16 @@ case: marker/fact without replay on follow-ups; common original UI components render both Core outputs. Usage presentation retains shared capacity with CM off/on, and legacy unknown history stays compatible. + - Child document generation, actual downloaded docx content and follow-up/history + rendering pass with no internal context or protocol wrappers. forbidden_side_effects: - No mutation of source assistant or source conversation, no injected model/tool results or engine request override, no credentials in public output; only owned fixture objects are cleaned. - No inferred success from HTTP 200, hidden rerun, lost historical rows, dedicated runtime renderer, duplicate final or tool execution on follow-up/history navigation. + - No internal history JSON/protocol envelopes in live or reloaded final answers; + no fabricated document success or follow-up tool replay. actor: Authorized Agent development user journey: - Upload and analyze with smolagents and follow up. @@ -98,3 +109,11 @@ case: - 2 forbidden_side_effects: - 2 + - id: AC-03 + description: Real child document generation and clean multi-turn history output. + steps: + - 4 + expected_results: + - 3 + forbidden_side_effects: + - 3 diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml index 3a07a54796..ad678a7638 100644 --- a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/execution.yaml @@ -4,11 +4,16 @@ implementations: - framework: playwright file: test.spec.ts selector: JIUWEN-REAL-UI-D4-001 - profiles: [real_smoke] + profiles: + - real_smoke timeout_seconds: 1800 acceptance_bindings: - AC-01: [JIUWEN-REAL-UI-D4-001] - AC-02: [JIUWEN-REAL-UI-D4-001] + AC-01: + - JIUWEN-REAL-UI-D4-001 + AC-02: + - JIUWEN-REAL-UI-D4-001 + AC-03: + - JIUWEN-REAL-UI-D4-001 preparation: families: [] anchors: false diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py index 74ec63620f..e1f242cd67 100644 --- a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py @@ -1,9 +1,12 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Owned deployment fixtures; private state and credentials never enter test output.""" import hashlib import json +import re import sys import time +import zipfile from pathlib import Path import httpx @@ -46,6 +49,15 @@ def main(): "provide_run_summary", "enable_protocol_repair_retry", "verification_config", "model_params_override"] values = {k: getattr(published, k) for k in keys} assert values == {k: getattr(draft, k) for k in keys}, "Draft and published source differ" + from services.agent_reasoning_service import snapshot_agent_reasoning_config + + # Match the management API's effective reasoning snapshot without writing the source. + values["model_params_override"] = snapshot_agent_reasoning_config( + model_ids=values["model_ids"], + requested_overrides=values["model_params_override"], + existing_overrides=values["model_params_override"], + tenant_id=published.tenant_id, + ) state = {"source": source, "user": user, "source_conversation": image.conversation_id, "source_agent_hash": digest(rows(session, AgentInfo, agent_id=source)), "source_history_hash": digest(rows(session, ConversationMessage, conversation_id=image.conversation_id)), @@ -107,6 +119,26 @@ def main(): print(json.dumps({"core": published.agent_core, "version": published.version_no, "messages": [{"id": m.message_id, "index": m.message_index, "role": m.message_role, "status": m.status, "hash": digest(m.message_content)} for m in messages]})) + elif mode == "verify-docx": + conversation, marker = int(sys.argv[3]), sys.argv[4] + with get_db_session() as session: + owner = session.query(ConversationRecord).filter_by(conversation_id=conversation).one() + assert owner.agent_id == state["agent_id"] and owner.created_by == state["user"] + latest = session.query(ConversationMessage).filter_by( + conversation_id=conversation, message_role="assistant").order_by( + ConversationMessage.message_id.desc()).first() + refs = re.findall(r"s3://[^\s)\]<>\"']+\.docx", latest.message_content) + assert refs, "Generated docx link is missing" + bucket, key = refs[-1][5:].split("/", 1) + artifact = path.with_suffix(".docx") + try: + success, _ = minio_client.download_file(key, str(artifact), bucket=bucket) + assert success, "Generated docx download failed" + with zipfile.ZipFile(artifact) as document: + assert marker in document.read("word/document.xml").decode(), "Document marker mismatch" + finally: + artifact.unlink(missing_ok=True) + print(json.dumps({"downloaded_docx_verified": True})) elif mode == "cleanup": with get_db_session() as session: assert digest(rows(session, AgentInfo, agent_id=state["source"])) == state["source_agent_hash"], "Source assistant changed" diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts index dfa3976093..de93c87a2a 100644 --- a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/test.spec.ts @@ -1,3 +1,4 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import { journey } from "../../infra/automation/d4/runner/journey"; import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; @@ -76,9 +77,17 @@ journey("JIUWEN-REAL-UI-D4-001", async ({ page, contract, expect }) => { const finalTexts = (row: any) => row.locator(".aui-md").evaluateAll((nodes: Element[]) => nodes .filter(node => !node.closest(".aui-reasoning-root") && !node.closest("[data-subagent-run-id]")) .map(node => (node as HTMLElement).innerText)); - const send = async (core: string, upload: boolean, query: string) => { + const renderedFinal = async (row: any) => { + await expect.poll(async () => (await finalTexts(row)).some(text => + text.includes(marker) && text.includes(input.expected_fact))).toBe(true); + const texts = await finalTexts(row); + expect(texts).toHaveLength(1); + expect(texts[0]).not.toMatch(/completed_action_history|historical_attachments|<\/?final_answer\b|MODEL_PRIVATE_HISTORY/); + return texts; + }; + const send = async (core: string, upload: boolean, query: string, document = false) => { if (upload) { - const uploadButton = page.getByRole("button", { name: /^(添加附件|上传文件)$/ }).first(); + const uploadButton = page.getByRole("button", { name: /^(添加附件|上传文件|上传附件)$/ }).first(); if (!await uploadButton.isVisible()) await page.getByRole("button", { name: "添加", exact: true }).click(); await expect(uploadButton).toBeVisible(); const [chooser] = await Promise.all([page.waitForEvent("filechooser"), uploadButton.click()]); @@ -131,13 +140,18 @@ journey("JIUWEN-REAL-UI-D4-001", async ({ page, contract, expect }) => { expect(results.length).toBeGreaterThan(0); expect(results.some(e => e.is_error === true)).toBe(false); expect(JSON.stringify(results)).toContain(input.expected_fact); + } else if (document) { + expect(events.some(e => e.type === "subagent_start")).toBe(true); + expect(final).toMatch(/s3:\/\/[^\s)]+\.docx/); + expect(fixture("verify-docx", String(conversation), marker).downloaded_docx_verified).toBe(true); } else expect(calls.filter(e => !["final_answer", "search_memory"].includes(e.tool_name))).toHaveLength(0); + expect(final).not.toMatch(/completed_action_history|historical_attachments|<\/?final_answer\b/); const number = outcomes.filter(e => e.kind === "turn").length + 1; await expect(roots()).toHaveCount(number, { timeout: 30000 }); const row = roots().last(); await expect(row.locator('[data-slot="aui_assistant-message-completion-indicator"]')).toBeVisible({ timeout: 30000 }); await expect(page.locator("[data-runtime-output]")).toHaveCount(0); - const texts = await finalTexts(row); + const texts = await renderedFinal(row); expect(texts).toHaveLength(1); expect(texts[0]).toContain(marker); expect(texts[0]).toContain(input.expected_fact); await expect(row.locator('[data-token-usage="summary"]')).toHaveCount(0); const usageButton=row.getByRole("button",{name:/\d+%.*本轮/}); @@ -206,7 +220,7 @@ journey("JIUWEN-REAL-UI-D4-001", async ({ page, contract, expect }) => { await configure("jiuwen");await enableCM();await openHistory(6);await send("jiuwen",false,follow); await configure("smolagents");await openHistory(7);await send("smolagents",false,follow);await openHistory(8); for (let index = 0; index < 8; index++) { - const text = await finalTexts(roots().nth(index)); + const text = await renderedFinal(roots().nth(index)); expect(text).toHaveLength(1); expect(text[0]).toContain(marker); expect(text[0]).toContain(input.expected_fact); const row=roots().nth(index);await expect(row.locator('[data-token-usage="summary"]')).toHaveCount(0); const usage=row.getByRole("button",{name:/\d+%.*本轮/});await expect(usage).toBeVisible();await usage.click(); @@ -216,7 +230,17 @@ journey("JIUWEN-REAL-UI-D4-001", async ({ page, contract, expect }) => { expect(signatures).toHaveLength(2); expect(signatures[0]).toEqual(signatures[1]); outcomes.push({ kind: "ui", common_components: true, image_views: imageViews, history_reload: true, run_count: runCount }); save(); }); + await contract.step(steps[3], async () => { + await configure("jiuwen"); await openHistory(8); + await send("jiuwen", false, `请调用文件生成子智能体,实际生成并上传 protocol_ui.docx,包含标题及一段正文,正文必须包含验收编号${marker}。最终回答提供文件链接、该验收编号和之前图片的衣服颜色,不要重新分析图片。`, true); + await openHistory(9); + await send("jiuwen", false, `只根据之前已完成的任务,回复生成的文档名称、图片衣服颜色和验收编号${marker}。不要调用任何工具或重新生成文件。`); + await openHistory(10); + for (let index = 0; index < 10; index++) await renderedFinal(roots().nth(index)); + outcomes.push({kind:"document",actual_docx_verified:true,clean_history:true,no_followup_replay:true}); save(); + }); const assertions = contract.expectedIds("assertions"); await contract.assertion(assertions[0], async () => { expect(outcomes.filter(e => e.kind === "turn" && e.upload)).toHaveLength(2); }); - await contract.assertion(assertions[1], async () => { expect(runCount).toBe(8); expect(previous).toHaveLength(16); await expect(page.locator("[data-runtime-output]")).toHaveCount(0); }); + await contract.assertion(assertions[1], async () => { expect(runCount).toBe(10); expect(previous).toHaveLength(20); await expect(page.locator("[data-runtime-output]")).toHaveCount(0); }); + await contract.assertion(assertions[2], async () => { expect(outcomes.some(e=>e.kind==="document"&&e.actual_docx_verified===true&&e.clean_history===true)).toBe(true); }); }); diff --git a/test-e2e/changes/bugs/JIUWEN-ANSWER-20261020.yaml b/test-e2e/changes/bugs/JIUWEN-ANSWER-20261020.yaml new file mode 100644 index 0000000000..7df2cf0922 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-ANSWER-20261020.yaml @@ -0,0 +1,49 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-ANSWER-20261020 + change_type: bugfix + bug_id: JIUWEN-ANSWER-20261020 + title: Normalize Jiuwen public answers before stream persistence and history projection + description: Filter internal context echoes and normalize explicit final envelopes + at the backend output boundary. + observed_behavior: Persisted root answers and live UI display completed_action_history + JSON plus final_answer tags. + expected_behavior: Live, saved and read-projected Jiuwen answers contain only validated + user-facing body; malformed boundaries fail safely, tool facts and frontend contracts + remain unchanged. + escaped_stage: D4 + coverage_gap: + type: missing_assertion + description: Existing output runtime and browser Cases checked finality and display + but lacked internal context/protocol echo variants. + feature_contract_changed: false + affected_features: + existing: + - JIUWEN-OUTPUT + modified: [] + added: [] + retired: [] + affected_cases: + existing: [] + modified: + - JIUWEN-OUTPUT-D3-001 + - JIUWEN-REAL-UI-D4-001 + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Fragmented internal context tags never leak; sole final body normalizes and + ambiguous final fails without tool execution + - Live final and terminal share clean content; history projection preserves source + records, user text, legacy messages and structured tool results + reason: Isolated parsing boundaries require exhaustive UT; real worker persistence + and browser history are distinct higher boundaries. + test_selectors: + - test/sdk/core/agents/test_jiuwen_answer_boundary.py + - test/backend/utils/test_runtime_history_projection.py + - test/backend/services/test_conversation_management_service.py::test_history_service_projects_old_jiuwen_answer_without_rewriting_user_or_stored_records + notes: Existing runtime-agent-core-factory design.md defines current acceptance. + No duplicate D1. OUTPUT D3 and real UI D4 strengthened; real child-docx smoke + separately checks artifact and history. Existing unrelated F-111 formal gate blocker + retained. diff --git a/test-e2e/changes/bugs/JIUWEN-AUTO-EFFORT-20261019.yaml b/test-e2e/changes/bugs/JIUWEN-AUTO-EFFORT-20261019.yaml new file mode 100644 index 0000000000..eda95dbab7 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-AUTO-EFFORT-20261019.yaml @@ -0,0 +1,42 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-AUTO-EFFORT-20261019 + change_type: bugfix + bug_id: JIUWEN-AUTO-EFFORT-20261019 + title: Preserve shared automatic reasoning omission semantics in the Jiuwen model bridge + description: Delegate the undeclared automatic effort sentinel to the existing + LegacyInvocationOptions resolver while rejecting unsupported explicit effort values. + observed_behavior: After configuration-side Core switching, management-normalized + reasoning_effort auto fails Jiuwen preflight with MODEL_OUTLET_OVERRIDE_UNDECLARED. + expected_behavior: Automatic effort inherits saved policy when no effort rule exists; + no undeclared field is emitted, saved profiles remain immutable, and explicit + unsupported efforts still fail closed. + escaped_stage: D4 + coverage_gap: + type: no_gap + description: The existing real-browser Core-switch Case detects the failure. + Bridge UT lacked management-normalized auto on a profile without an effort rule; + add that isolated regression without a duplicate D1 Case. + feature_contract_changed: false + affected_features: + existing: [JIUWEN-MODEL-BRIDGE, JIUWEN-OUTPUT] + modified: [] + added: [] + retired: [] + affected_cases: + existing: [JIUWEN-MODEL-BRIDGE-D3-001] + modified: [JIUWEN-REAL-UI-D4-001] + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Automatic effort without a saved rule uses shared omission semantics for field and legacy extra aliases + - Unsupported explicit effort remains rejected and original snapshots stay unchanged + reason: The backend bridge validated the inherited sentinel before the shared + resolver could apply its established omission rule. + test_selectors: + - test/backend/services/test_model_runtime_provisioning_service.py + notes: Existing runtime-agent-core-factory design.md owns this fix. Reuse the + real-model D4 and bridge D3; preserve failures and global F-111 blocker. No catalog + inference, provider field injection, model/Core fallback, SQL or retry change. diff --git a/test-e2e/changes/bugs/JIUWEN-PROTOCOL-20261020.yaml b/test-e2e/changes/bugs/JIUWEN-PROTOCOL-20261020.yaml new file mode 100644 index 0000000000..ac7bab029d --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-PROTOCOL-20261020.yaml @@ -0,0 +1,54 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-PROTOCOL-20261020 + change_type: bugfix + bug_id: JIUWEN-PROTOCOL-20261020 + title: Accept bound Qwen stream placeholders and clarify child action protocol feedback + description: Preserve bound tool call identity through empty-string continuation + placeholders; improve invalid Python and mixed action/final feedback and platform + examples. + observed_behavior: Qwen image tool stream fails MODEL_STREAM_IDENTITY_CONFLICT. + Child CodeAgent copies invalid pip pseudo-code and receives misleading optional-clarification + feedback; valid code combined with a final envelope receives generic preamble + feedback. + expected_behavior: Only already-bound empty IDs are omitted. Real conflicts and + incomplete calls remain blocked. Child code/final responses stay mutually exclusive + with accurate feedback and executable shell-free pip examples. + escaped_stage: D4 + coverage_gap: + type: missing_boundary + description: Native runtime SSE coverage lacked an already-bound empty tool ID; + isolated legacy child feedback assertions were absent. Strengthen the existing + runtime Case and add UT; reuse the real UI journey. + feature_contract_changed: false + affected_features: + existing: + - JIUWEN-MODEL-BRIDGE + - JIUWEN-RESOURCES + - JIUWEN-OUTPUT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-REAL-UI-D4-001 + modified: + - JIUWEN-MODEL-BRIDGE-D3-001 + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Bound empty continuation keeps identity and complete arguments; initial empty, + whitespace, nonstring and conflicting IDs fail + - Mixed executable action/final output never executes; malformed Python gets accurate + syntax and dependency-install feedback; valid code still runs + reason: Unit contracts need regression assertions while deployed provider and + child artifact results require real smoke evidence. + test_selectors: + - test/sdk/core/model_service/test_qwen_stream_placeholders.py + - test/sdk/core/agents/test_legacy_action_feedback.py + notes: Existing runtime-agent-core-factory design.md records impact and verification. + No duplicate D1. Child retains its own saved Core. Reuse real UI and add owned + real-provider smoke for both saved model outlets plus document generation. Global + F-111-RULE-02 asset issue is independent and remains a blocker. diff --git a/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml b/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml index 3330ad69e0..2606dd037a 100644 --- a/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml +++ b/test-e2e/changes/test-fixes/JIUWEN-REAL-UI-20261019.yaml @@ -22,3 +22,16 @@ change: calls and config-side published switching together. New D4 proves this user journey; no duplicate D1/UT, no product code change. Global pre-existing F-111 reference is an independent blocker. + After merging develop, the shared Composer labels its upload + button with workbench.controls.uploadAttachment. Accept that observed localized + label alongside the legacy labels; retain the same filechooser upload and all + model/tool, capacity, history, no-replay and cleanup assertions. The initial + locator timeout remains separate failed evidence, with no model invocation. + The fixture computes the source's effective reasoning snapshot with the same + read-only canonicalizer used by the management API before cloning. It still + compares all published configuration fields exactly against that frozen + expectation and hashes the unmodified source configuration and history. + Poll the original Markdown renderer for the same required fact and marker + before inspecting its final text; the completion indicator precedes the end + of the shared typing animation. Retain exactly-one-final and history reload + checks, without changing the timeout or accepting partial output. diff --git a/test-e2e/infra/automation/jiuwen_bridge_fixture.py b/test-e2e/infra/automation/jiuwen_bridge_fixture.py index fc8def89bb..51093e83b1 100644 --- a/test-e2e/infra/automation/jiuwen_bridge_fixture.py +++ b/test-e2e/infra/automation/jiuwen_bridge_fixture.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Controlled real HTTP/SSE fixtures for Jiuwen model bridge runtime cases.""" import asyncio @@ -87,6 +89,10 @@ async def handle(reader, writer): content, calls = "", [call] else: content, calls = "SPIKE_FINAL_RESULT_5", [] + if scenario == "echo_history": + content = ('MODEL_PRIVATE_HISTORY' + '{"tool":"add","arguments":{"a":2,"b":3}}' + 'SPIKE_FINAL_RESULT_5') finish = "tool_calls" if calls else "stop" if scenario == "unknown_finish": finish = "mystery_control" @@ -107,6 +113,9 @@ async def frame(delta, finish_reason=None): state["first_fragment_no_tool"] = not state["tool_calls"] if scenario == "broken_stream": return + if scenario == "empty_call_id": + await frame({"tool_calls": [{"index": 0, "id": "", "type": "function", + "function": {"arguments": ""}}]}) await frame({"tool_calls": [{"index": 0, "function": { "arguments": '2,"b":3}' if scenario != "bad_args" else "", }}]}) diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 3ef095c7b9..aa416c91bb 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -2041,3 +2041,28 @@ def test_message_database_failure_preserves_unexpected_error(): if __name__ == '__main__': unittest.main() + + +def test_history_service_projects_old_jiuwen_answer_without_rewriting_user_or_stored_records(): + import copy + import json + from backend.services import conversation_management_service as service + + raw = 'PRIVATEClean link' + envelope = {"schema_version": 1, "runtime_mode": "native_agent", "agent_core": "jiuwen", + "type": "run_terminal", "status": "completed", "final_content": raw, "final_block_ids": ["b"]} + history = {"conversation_id": 123, "create_time": "2026-10-20", "search_records": [], "image_records": [], + "message_records": [ + {"message_id": 1, "role": "user", "message_content": raw, "units": []}, + {"message_id": 2, "role": "assistant", "message_content": raw, "opinion_flag": None, + "units": [{"unit_type": "run_terminal", "unit_content": json.dumps(envelope)}]}, + ]} + original = copy.deepcopy(history) + with patch.object(service, "get_conversation_history", return_value=history): + result = service.get_conversation_history_service(123, "owner")[0] + assert result["message"][0]["message"] == raw + answer = result["message"][1] + assert answer["status"] == "completed" + assert next(u for u in answer["message"] if u["type"] == "final_answer")["content"] == "Clean link" + assert "PRIVATE" not in json.dumps(answer) + assert history == original diff --git a/test/backend/services/test_model_runtime_provisioning_service.py b/test/backend/services/test_model_runtime_provisioning_service.py index 07a02f4a99..40e65c7574 100644 --- a/test/backend/services/test_model_runtime_provisioning_service.py +++ b/test/backend/services/test_model_runtime_provisioning_service.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Management provisioning and request snapshot guards with external I/O isolated.""" import json @@ -198,3 +200,32 @@ def test_record_factory_and_request_overlay_preserve_trusted_outlet(saved): assert base.temperature == 0.7 assert base.resolved_profile.model_dump() == original assert overlay.resolved_profile.model_dump() == original + + +@pytest.mark.parametrize("extra", [{}, {"reasoning_effort": "auto"}]) +def test_auto_effort_without_saved_rule_uses_shared_omission_semantics(saved, extra): + """Management's automatic reasoning default must not become an undeclared API field.""" + profile = restore_runtime_model_snapshot(saved, "tenant") + original = profile.model_dump() + assert "reasoning_effort" not in {rule["key"] for rule in profile.parameter_rules} + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, temperature=.7, + enable_thinking=True, reasoning_effort="auto", extra_body=extra) + effective = compile_model_runtime_profile(model, SimpleNamespace( + context_budget_snapshot=None, requested_output_tokens=None)) + assert "reasoning_effort" not in effective.parameter_values + assert all(item["path"] != "/reasoning_effort" for item in effective.extra_api_parameters) + assert effective.parameter_values["enable_thinking"] is True + assert profile.model_dump() == original + assert model.reasoning_effort == "auto" + + +@pytest.mark.parametrize("effort", ["low", "high", "none"]) +def test_explicit_effort_without_saved_rule_remains_rejected(saved, effort): + profile = restore_runtime_model_snapshot(saved, "tenant") + model = ModelConfig(cite_name="outlet", model_name=profile.identity.request_model_id, + url=profile.connection.base_url, resolved_profile=profile, temperature=.7, + enable_thinking=True, reasoning_effort=effort) + with pytest.raises(ValueError, match="MODEL_OUTLET_OVERRIDE_UNDECLARED"): + compile_model_runtime_profile(model, SimpleNamespace( + context_budget_snapshot=None, requested_output_tokens=None)) diff --git a/test/backend/utils/test_runtime_history_projection.py b/test/backend/utils/test_runtime_history_projection.py new file mode 100644 index 0000000000..77358e907f --- /dev/null +++ b/test/backend/utils/test_runtime_history_projection.py @@ -0,0 +1,51 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Read projection cleans proven runtime text without mutating audit or tool records.""" + +import copy +import json + +from utils.runtime_output import project_runtime_history + +RAW = 'PRIVATE[doc](s3://owned/doc.docx)' +CLEAN = '[doc](s3://owned/doc.docx)' + + +def unit(kind, **kwargs): + event = {"schema_version": 1, "runtime_mode": "native_agent", "agent_core": "jiuwen", + "type": kind, "turn_id": "turn", "block_id": "block", **kwargs} + return {"unit_type": kind, "unit_content": json.dumps(event), "invocation_id": kwargs.get("invocation_id")} + + +def test_dirty_saved_runtime_has_same_clean_terminal_final_and_block_without_db_mutation(): + units = [unit("model_output", content=RAW), unit("run_terminal", status="completed", final_content=RAW, + content=json.dumps({"final_content": RAW}), + final_block_ids=["block"]), {"unit_type": "final_answer", "unit_content": RAW}, + unit("tool_result", result=RAW, content=RAW)] + before = copy.deepcopy(units) + final, projected, invalid = project_runtime_history(RAW, units) + assert not invalid and final == CLEAN and units == before + assert json.loads(projected[0]["unit_content"])["content"] == CLEAN + assert json.loads(projected[1]["unit_content"])["final_content"] == CLEAN + assert "PRIVATE" not in projected[1]["unit_content"] + assert projected[2]["unit_content"] == CLEAN + assert projected[3] == before[3] + + +def test_plain_legacy_and_child_smol_content_are_preserved(): + legacy = [{"unit_type": "final_answer", "unit_content": RAW}] + assert project_runtime_history(RAW, legacy) == (RAW, legacy, False) + child = {"unit_type": "final_answer", "unit_content": "child result", "invocation_id": "smol"} + units = [unit("model_output", content="root"), child] + _, projected, _ = project_runtime_history("root", units) + assert projected[-1] == child + + +def test_invalid_legacy_runtime_final_is_not_confirmed_and_partial_history_stays_hidden(): + raw = 'PRIVATE' + units = [unit("model_output", content=raw), unit("run_terminal", status="completed", final_content=raw, + final_block_ids=["block"])] + final, projected, invalid = project_runtime_history(raw, units) + assert invalid and final == "" + assert json.loads(projected[0]["unit_content"])["content"] == "" + result = json.loads(projected[1]["unit_content"]) + assert result["status"] == "failed" and result["final_content"] == "" and not result["final_block_ids"] diff --git a/test/sdk/core/agents/test_jiuwen_answer_boundary.py b/test/sdk/core/agents/test_jiuwen_answer_boundary.py new file mode 100644 index 0000000000..c4a940e5a1 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_answer_boundary.py @@ -0,0 +1,96 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Internal context cannot enter public model deltas or confirmed answers.""" + +import json +from types import SimpleNamespace + +import pytest +from nexent.core.agent_runtime_support.answer import ( + AnswerBoundaryError, + PublicTextFilter, + normalize_public_answer, +) +from nexent.core.agents.core_adapters.events import JiuwenEventAdapter +from nexent.core.model_service.contracts import AssistantMessage +from nexent.core.utils.observer import MessageObserver + +HISTORY = 'PRIVATE_RECORD {"url":"s3://private/hidden"}' +ANSWER = '文件已生成:[下载](s3://nexent/workspace/report.docx)\n\n```python\nprint("example")\n```' +RAW = HISTORY + "\n" + ANSWER + "" + + +@pytest.mark.parametrize("split", range(len(RAW) + 1)) +def test_every_fragment_boundary_hides_history_and_final_markers(split): + public = PublicTextFilter() + first = public.push(RAW[:split]) + assert "PRIVATE_RECORD" not in first and "s3://private" not in first + assert "PRIVATE', 'missing close', + 'AB', + 'A', + 'unexpected preface A', + 'danger()done', + 'danger()', + '', 'PRIVATEPRIVATE' + 'PRIVATE' + 'Answer: x < 3, y > 2') + assert normalize_public_answer(text) == 'Answer: x < 3, y > 2' + + +def test_adapter_confirmed_final_and_block_match_clean_wire_content(): + observer = MessageObserver() + adapter = JiuwenEventAdapter(observer, "agent", "turn") + adapter.begin_step(None) + for char in RAW: + adapter.model_event(SimpleNamespace(type="text_delta", delta=char)) + adapter.model_event(SimpleNamespace(type="message_completed", message=AssistantMessage( + content_parts=[{"type": "text", "text": RAW}], finish_reason="stop"))) + adapter.emit({"type": "answer", "payload": {"output": RAW}}) + result = adapter.finish() + events = [json.loads(item) for item in observer.get_cached_message()] + assert result.status == "completed" and result.final_content == ANSWER + assert "PRIVATE_RECORD" not in json.dumps(events) + assert "completed_action_history" not in json.dumps(events) + assert "" not in json.dumps(events) + selected = ''.join(e["content"] for e in events if e.get("block_id") in result.final_block_ids + and e["type"] == "model_output") + assert selected.strip() == ANSWER + assert sum(e["type"] == "final_answer" for e in events) == 1 + + +def test_bad_terminal_returns_failed_result_without_exposing_rejected_answer(): + adapter = JiuwenEventAdapter(MessageObserver(), "agent", "turn") + with pytest.raises(AnswerBoundaryError): + adapter.emit({"type": "answer", "payload": {"output": 'unfinished'}}) + result = adapter.finish() + assert result.status == "failed" and result.end_reason == "JIUWEN_FINAL_OUTPUT_INVALID" + assert result.final_content == "" and result.final_block_ids == [] + + +def test_filters_are_per_block_and_tool_results_remain_exact(): + observer = MessageObserver() + adapter = JiuwenEventAdapter(observer, "agent", "turn") + adapter.begin_step(None) + adapter.model_event(SimpleNamespace(type="text_delta", delta='PRIVATE')) + adapter.begin_step(None) + adapter.model_event(SimpleNamespace(type="text_delta", delta="Public new step")) + adapter.tool_result("call", "provider", RAW) + events = [json.loads(item) for item in observer.get_cached_message()] + assert any(e["type"] == "model_output" and e["content"] == "Public new step" for e in events) + assert next(e for e in events if e["type"] == "tool_result")["result"] == RAW diff --git a/test/sdk/core/agents/test_legacy_action_feedback.py b/test/sdk/core/agents/test_legacy_action_feedback.py new file mode 100644 index 0000000000..b63a6488d2 --- /dev/null +++ b/test/sdk/core/agents/test_legacy_action_feedback.py @@ -0,0 +1,63 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Legacy feedback distinguishes invalid Python and mixed action/final responses.""" +import pytest +from test_pr4005_continuation import _agent_with_responses, core_agent_module + + +class ExecutionError(Exception): + def __init__(self, message, logger=None): + super().__init__(message) + + +@pytest.fixture(autouse=True) +def exception_types(monkeypatch): + monkeypatch.setattr(core_agent_module, "AgentExecutionError", ExecutionError) + monkeypatch.setattr(core_agent_module, "AgentGenerationError", ExecutionError) + + +def test_mixed_code_and_final_is_rejected_with_actionable_feedback(monkeypatch): + output = 'print("done")done' + agent, _, _ = _agent_with_responses([output], strict=False, monkeypatch=monkeypatch) + list(agent._run_stream("task", max_steps=1)) + agent.python_executor.assert_not_called() + assert "Do not combine" in str(agent.memory.steps[0].error) + + +def test_shell_syntax_gets_python_feedback_before_execution(monkeypatch): + output = 'import sys\nsys.executable -m pip install python-docx' + agent, _, _ = _agent_with_responses([output], strict=False, monkeypatch=monkeypatch) + agent.clarification_tool_name = "ask_user" + list(agent._run_stream("task", max_steps=1)) + agent.python_executor.assert_not_called() + assert "Invalid Python action" in str(agent.memory.steps[0].error) + assert "subprocess.run" in str(agent.memory.steps[0].error) + + +def test_valid_action_still_executes_with_optional_clarification(monkeypatch): + agent, _, _ = _agent_with_responses(['print("done")'], strict=False, monkeypatch=monkeypatch) + agent.clarification_tool_name = "ask_user" + list(agent._run_stream("task", max_steps=1)) + agent.python_executor.assert_called_once() + + +def test_final_answer_may_contain_nonexecutable_markdown_code(monkeypatch): + answer = 'Example:\n```python\nprint("example")\n```' + agent, _, _ = _agent_with_responses( + [f"{answer}"], strict=False, monkeypatch=monkeypatch + ) + outputs = list(agent._run_stream("task", max_steps=1)) + agent.python_executor.assert_not_called() + assert outputs[-1].output == answer + + +def test_mixed_action_recovers_on_next_step_without_executing_it_twice(monkeypatch): + agent, _, _ = _agent_with_responses( + ['print("unsafe mixed")done', + 'print("confirmed action")', 'done'], + strict=False, + monkeypatch=monkeypatch, + ) + agent.python_executor.return_value.is_final_answer = False + outputs = list(agent._run_stream("task", max_steps=3)) + agent.python_executor.assert_called_once_with('print("confirmed action")') + assert outputs[-1].output == "done" diff --git a/test/sdk/core/model_service/test_qwen_stream_placeholders.py b/test/sdk/core/model_service/test_qwen_stream_placeholders.py new file mode 100644 index 0000000000..53a46d67e0 --- /dev/null +++ b/test/sdk/core/model_service/test_qwen_stream_placeholders.py @@ -0,0 +1,53 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Qwen streamed tool identities retain their established call binding.""" +import json + +import pytest +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError +from nexent.core.model_service.adapters.openai_stream import OpenAIStreamDecoder +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.sse import SSEEvent + + +@pytest.fixture +def stream_request(openai_profile): + return ModelRequest( + request_id="qwen-placeholder-regression", + outlet_id=openai_profile.outlet_id, + config_revision=openai_profile.config_revision, + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "hello"}])], + stream=True, + ) + + +def chunk(call=None, finish=None): + return SSEEvent(json.dumps({"id": "response", "model": "model", "choices": [{ + "index": 0, "delta": {"tool_calls": [call]} if call else {}, "finish_reason": finish}]})) + + +def test_qwen_empty_id_continuation_preserves_bound_call(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(chunk({"index": 0, "id": "bound-call", "type": "function", + "function": {"name": "search", "arguments": ""}})) + decoder.push(chunk({"index": 0, "id": "", "type": "function", "function": {"arguments": ""}})) + decoder.push(chunk({"index": 0, "function": {"arguments": '{"query":"owned"}'}})) + decoder.push(chunk(finish="tool_calls")) + completed = decoder.push(SSEEvent("[DONE]"))[0].message + assert len(completed.tool_calls) == 1 + assert completed.tool_calls[0].provider_call_id == "bound-call" + assert completed.tool_calls[0].name == "search" + assert completed.tool_calls[0].parsed_arguments == {"query": "owned"} + + +@pytest.mark.parametrize("identity", ["other-call", " ", 0]) +def test_real_identity_conflicts_still_fail(openai_profile, stream_request, identity): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(chunk({"index": 0, "id": "bound-call", "function": {"name": "search", "arguments": ""}})) + with pytest.raises(ModelAdapterError, match="MODEL_STREAM_IDENTITY_CONFLICT"): + decoder.push(chunk({"index": 0, "id": identity, "function": {"arguments": "{}"}})) + + +def test_empty_id_cannot_establish_a_tool_call(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + with pytest.raises(ModelAdapterError, match="MODEL_STREAM_IDENTITY_CONFLICT"): + decoder.push(chunk({"index": 0, "id": "", "function": {"name": "search", "arguments": "{}"}})) From f44f78ea1f871f5f40c6ee2781dbe8dba1d34911 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Thu, 22 Oct 2026 11:10:02 +0800 Subject: [PATCH 10/26] fix(jiuwen): complete authorized skills and native resource execution Propagate saved Skill authorization snapshots, provide controlled native Python file generation, and resolve registered MCP names to original wire names while rejecting collisions. Add unit regressions and deployed resource/Skill journey cases covering files, knowledge retrieval, external MCP, mixed-core delegation, and follow-up history without replay. --- backend/agents/create_agent_info.py | 6 +- .../core/agents/core_adapters/jiuwen.py | 39 ++- .../agents/core_adapters/jiuwen_protocol.py | 3 + sdk/nexent/core/agents/core_adapters/mcp.py | 21 +- .../cases/JIUWEN-RESOURCES-D3-001/case.yaml | 4 +- .../JIUWEN-RESOURCES-D3-001/test_resources.py | 5 +- .../cases/JIUWEN-RESOURCES-D3-003/case.yaml | 109 +++++++ .../deployed_resources.py | 285 ++++++++++++++++++ .../JIUWEN-RESOURCES-D3-003/execution.yaml | 21 ++ .../JIUWEN-RESOURCES-D3-003/test_deployed.py | 47 +++ .../cases/JIUWEN-SKILL-FILES-D4-001/case.yaml | 85 ++++++ .../JIUWEN-SKILL-FILES-D4-001/execution.yaml | 19 ++ .../JIUWEN-SKILL-FILES-D4-001/fixture.py | 169 +++++++++++ .../JIUWEN-SKILL-FILES-D4-001/test.spec.ts | 108 +++++++ .../bugs/JIUWEN-MCP-NAMES-20261021.yaml | 48 +++ .../bugs/JIUWEN-SAVED-SKILLS-20261021.yaml | 54 ++++ .../JIUWEN-RESOURCE-ACCEPTANCE-20261021.yaml | 28 ++ .../features/JIUWEN-RESOURCES/feature.yaml | 6 +- .../automation/jiuwen_resource_provider.py | 10 +- .../automation/jiuwen_resource_runtime.py | 42 ++- test/backend/agents/test_create_agent_info.py | 21 ++ test/sdk/core/agents/test_jiuwen_history.py | 5 + test/sdk/core/agents/test_jiuwen_mcp_names.py | 62 ++++ .../core/agents/test_jiuwen_model_service.py | 26 ++ .../core/agents/test_jiuwen_native_files.py | 98 ++++++ 25 files changed, 1294 insertions(+), 27 deletions(-) create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-003/case.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-003/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-RESOURCES-D3-003/test_deployed.py create mode 100644 test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py create mode 100644 test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/test.spec.ts create mode 100644 test-e2e/changes/bugs/JIUWEN-MCP-NAMES-20261021.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-SAVED-SKILLS-20261021.yaml create mode 100644 test-e2e/changes/test-fixes/JIUWEN-RESOURCE-ACCEPTANCE-20261021.yaml create mode 100644 test/sdk/core/agents/test_jiuwen_mcp_names.py create mode 100644 test/sdk/core/agents/test_jiuwen_native_files.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 7240eaa6ca..8e5dcc5abd 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -1124,8 +1124,7 @@ def _get_skill_script_tools( params={ "local_skills_dir": skill_snapshot_root or CONTAINER_SKILLS_PATH, "isolated_skills_root": bool(skill_snapshot_root), - **({"authorized_skill_names": sorted(skill_config_values)} - if runtime_skill_snapshot is not None else {}), + "authorized_skill_names": sorted(skill_config_values), }, source="builtin", usage="builtin", @@ -1141,8 +1140,7 @@ def _get_skill_script_tools( params={ "local_skills_dir": skill_snapshot_root or CONTAINER_SKILLS_PATH, "config_overrides": skill_config_values, - **({"authorized_skill_names": sorted(skill_config_values)} - if runtime_skill_snapshot is not None else {}), + "authorized_skill_names": sorted(skill_config_values), }, source="builtin", usage="builtin", diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/core_adapters/jiuwen.py index 17a2ac1d7a..9b01e21943 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen.py +++ b/sdk/nexent/core/agents/core_adapters/jiuwen.py @@ -135,9 +135,10 @@ def validate(self): registry = {tool.name or tool.class_name: tool for tool in self.tool_configs} if len(registry) != len(self.tool_configs): raise ValueError("Duplicate tool names in agent configuration") - from .jiuwen_protocol import EXECUTION_TOOL + from .jiuwen_protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL - if EXECUTION_TOOL in registry: + reserved = {EXECUTION_TOOL, NATIVE_EXECUTION_TOOL} + if reserved.intersection(registry): raise UnsupportedAgentCoreConfig("Reserved Jiuwen execution tool name") self.visible_tool_names = set(registry) if config.prompt_tool_policy_snapshot is not None: @@ -152,7 +153,7 @@ def validate(self): collaborator_names = [child.invocation_name or child.name for child in config.worker_agents] collaborator_names.extend(child.name for child in config.external_a2a_agents) all_names = [*registry, *collaborator_names] - if len(all_names) != len(set(all_names)) or EXECUTION_TOOL in collaborator_names: + if len(all_names) != len(set(all_names)) or reserved.intersection(collaborator_names): raise UnsupportedAgentCoreConfig("Duplicate or reserved collaboration tool name") self.visible_tool_names.update(collaborator_names) if any(tool.source == "mcp" for tool in self.tool_configs) and not self.run_info.mcp_host: @@ -277,8 +278,7 @@ def invoke_remote(task, _config=external): if any(config.class_name == "ParallelExecutorTool" and (config.name or "parallel_executor") in self.visible_tool_names for config in self.tool_configs): self._bind_parallel_tool() - if self.text_protocol: - self._bind_text_executor() + self._bind_text_executor() def _business_handler(self, name, forward, *, lane="model-tool-io"): async def execute(**arguments): @@ -358,7 +358,24 @@ def _compile_context(self): " Never copy internal history or attachment envelopes into your response. " "Return only the user-facing answer as ordinary Markdown, without final_answer protocol tags." ) - return prompt + "\n\n" + current_task_guidance + resource_guidance = ( + "Skills are authorized local resources, not S3 objects. Read a listed skill with read_skill_md " + "and its configuration with read_skill_config. Run only a script actually listed in that skill " + "using run_skill_script; never invent skill names, script paths or S3 paths. " + "download_from_s3 is only for actual authorized uploaded/generated file references, never for " + "skills or missing inputs. Do not retry a denied or missing file using guessed path variants. " + "For a new file request no download is needed. Follow the authorized skill instructions, then " + "create the requested file with controlled Python in the current outputs directory. " + "Use Python libraries and shell-free pip argv only when the configured sandbox permits it. " + "Upload the actual generated file with upload_to_s3 and use the returned file link. " + "Never claim a file exists or was uploaded without successful tool observations." + ) + if not self.text_protocol: + resource_guidance += ( + " Call nexent_execute_python with a complete Python code string to create files. " + "Code in ordinary prose or code tags is not executed in native mode." + ) + return prompt + "\n\n" + current_task_guidance + "\n\n" + resource_guidance def _create_model(self): from ...model_service.registry import AuthenticationRegistry @@ -417,7 +434,7 @@ def _bind_text_executor(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard from ..sandbox import SandboxConfig, _execute_with_tool_context, build_python_executor - from .jiuwen_protocol import EXECUTION_TOOL + from .jiuwen_protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL executor = self.resources.agent.python_executor if executor is None: @@ -434,11 +451,15 @@ def execute(code): return json.dumps({"output": str(output.output), "logs": output.logs, "is_final_answer": output.is_final_answer}, ensure_ascii=False) - card = ToolCard(name=EXECUTION_TOOL, description="Execute a validated controlled text block", + name = EXECUTION_TOOL if self.text_protocol else NATIVE_EXECUTION_TOOL + card = ToolCard(name=name, description=( + "Execute complete Python in the configured Nexent sandbox and current outputs directory. " + "Use this to create files following an authorized Skill. Only authorized Nexent tools are bound; " + "sandbox import, filesystem, network and shell restrictions apply. Return actual output and logs."), input_params={"type": "object", "properties": {"code": {"type": "string"}}, "required": ["code"], "additionalProperties": False}) self.native_agent.ability_manager.add_ability( - card, LocalFunction(card, self._business_handler(EXECUTION_TOOL, execute, lane="sandbox")), + card, LocalFunction(card, self._business_handler(name, execute, lane="sandbox")), ) def _skill_rails(self): diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py b/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py index fddbab6fc1..2a34c4f963 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py +++ b/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Strict recognition of the existing, explicitly executable text protocol.""" import ast @@ -6,6 +8,7 @@ EXECUTION_TOOL = "nexent_execute_block" +NATIVE_EXECUTION_TOOL = "nexent_execute_python" PROTOCOL_INSTRUCTION = ( "This outlet uses the controlled text execution protocol, not native function calls. " "Only complete ... blocks request Python execution with the listed authorized tools. " diff --git a/sdk/nexent/core/agents/core_adapters/mcp.py b/sdk/nexent/core/agents/core_adapters/mcp.py index c79c7a67b6..8344850133 100644 --- a/sdk/nexent/core/agents/core_adapters/mcp.py +++ b/sdk/nexent/core/agents/core_adapters/mcp.py @@ -1,7 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Jiuwen native MCP tools with Nexent managed session ownership.""" import asyncio +import keyword import logging +import re from contextlib import AsyncExitStack, nullcontext from copy import deepcopy from types import SimpleNamespace @@ -18,6 +22,16 @@ logger = logging.getLogger(__name__) +def _registered_tool_name(name): + """Match the naming contract used by Nexent's MCP registration adapter.""" + normalized = re.sub(r"[^\w_]", "", name.replace("-", "_")) + if normalized and normalized[0].isdigit(): + normalized = "_" + normalized + if keyword.iskeyword(normalized): + normalized += "_" + return normalized + + class JiuwenMCPCollection(ManagedMCPToolCollection): """Reuse bounded lifecycle ownership, never instantiate a smolagents MCP adapter.""" @@ -49,10 +63,9 @@ async def _run_async_session(self): if not await client.connect(timeout=self.connect_timeout_seconds): raise MCPConnectionTimeoutError("Jiuwen MCP connection failed") for card in await client.list_tools(timeout=self.connect_timeout_seconds): - if card.name in available: - available[card.name] = None - else: - available[card.name] = (client, card) + # The public saved name may be normalized; the proxy keeps the raw wire name. + for name in {card.name, _registered_tool_name(card.name)} - {""}: + available[name] = None if name in available else (client, card) tools = [] for tool_config in self.authorized_tools: diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml index fc684569b1..fad66417c3 100644 --- a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/case.yaml @@ -34,7 +34,9 @@ case: workers are reclaimed. expected_results: - Native and text protocols execute authorized local/MCP/Skills once, inject hidden - caller identity and return actual observations. + caller identity and return actual observations. Native Docker mode also creates + a real workspace file through the reserved controlled Python tool. Registered + normalized MCP class names resolve to the original hyphenated wire tool name. - Follow-up does not replay effects; owned tools, sessions and managed workers are reclaimed. forbidden_side_effects: diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py index e094f8f7e7..275b6255f2 100644 --- a/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-001/test_resources.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Real Jiuwen resources through canonical model transport.""" import asyncio import json @@ -26,7 +28,8 @@ def test_resource_protocol_roundtrip(monkeypatch, tmp_path, protocol, scenario): assert calls == [(2,3)] and state["mcp_calls"] == 1 assert state["user_id"] == "captured-user" assert effects == ("" if scenario == "parallel" else "x") - assert state["model_calls"] == (2 if scenario == "parallel" else 5) + assert state["model_calls"] == (2 if scenario == "parallel" else + 6 if scenario == "docker" and protocol == "native" else 5) assert state["saw_skill"] assert all(bool(body.get("tools")) == (protocol == "native") for body in state["requests"]) body = json.dumps(state["requests"]) diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-003/case.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/case.yaml new file mode 100644 index 0000000000..9df38ca439 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/case.yaml @@ -0,0 +1,109 @@ +schema_version: '1.0' +stage: D3 +module: AGENT-CORE +case: + case_id: JIUWEN-RESOURCES-D3-003 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-RESOURCE-ACCEPTANCE-20261021 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-001 + - JIUWEN-RESOURCES-RULE-004 + title: Deployed real Skills external MCP knowledge retrieval and mixed Core child + type: AGENT-IT + priority: P0 + status: active + automation: automated + objective: Prove current published configuration and real downstream resource execution + through authenticated deployed Agent APIs. + preconditions: + - Authorized saved Skill assistant and knowledge assistant, enabled registered external + MCP service, populated accessible KB with configured embedding, real saved parent/child + model outlets and current deployment. The full sandbox with Node and docx must + be selected for the docx Skill's JavaScript workflow. + test_data: + profile: real_smoke + steps: + - order: 1 + action: Clone authorized saved Skill and knowledge configurations; create isolated + published Jiuwen MCP and smolagents parent fixtures. Pin the parent to the owned + published Jiuwen child version and use separate saved model outlets. + expected: Source hashes unchanged, tool/Skill/KB permissions retained and immutable + child Core/model configuration verified. + - order: 2 + action: Run Jiuwen Skill docx generation, registered external MCP date query and + actual configured KB retrieval through deployed authenticated run API; follow + up from persisted history. + expected: Observed authorized calls and successful results; downloaded docx contains + fresh marker, external MCP result contains current date, actual KB result includes + indexed file and relevant snippets. Follow-up calls no resource. + - order: 3 + action: Ask the smolagents parent to delegate docx generation to its Jiuwen child; + follow up and read persisted conversation records. Clean only owned entities. + expected: Balanced nested events, actual Jiuwen step/result and child Skill/Python/upload + execution; downloaded child document contains marker; parent returns genuine + result, no follow-up replay, original history/config unchanged, cleanup succeeds. + expected_results: + - Real Skill read and successful workspace JavaScript script execution in the full + sandbox generate/upload verified docx; no guessed Skill S3 download or Python + fallback conceals a missing Node runtime. + - Real registered external MCP completes authorized call and returns current date; + no local/mock tool substitutes for remote execution. + - Actual allowed knowledge index retrieval returns nonempty indexed file/snippets + and grounded answer with intact history. + - smolagents parent invokes pinned Jiuwen child with its own model outlet; nested + actual file generation and follow-up succeed without replay. + - All source configuration/Skills/KB bindings and source history remain unchanged; + only owned fixtures are removed. + forbidden_side_effects: + - No injected results, credential output, source mutation, widened permissions or + fabricated success from HTTP200. + - No execution of wrong ambiguous MCP tool, replacement of external service with + Mock, child Core/model override from parent or historic resource replay. + acceptance: + - id: AC-01 + description: Actual Skill and external MCP execution with real saved outlets. + steps: + - 1 + - 2 + expected_results: + - 1 + - 2 + forbidden_side_effects: + - 1 + - 2 + - id: AC-02 + description: Authorized real knowledge retrieval and persisted follow-up. + steps: + - 2 + expected_results: + - 3 + forbidden_side_effects: + - 1 + - id: AC-03 + description: Mixed Core nested effects, independent child configuration and isolated + cleanup. + steps: + - 1 + - 3 + expected_results: + - 4 + - 5 + forbidden_side_effects: + - 1 + - 2 + execution: + profiles: + - name: real_smoke + required_services: + - model-provider + timeout_seconds: 1200 + integration_boundary: + - Deployed management/runtime API, immutable saved agent/outlet/Skill/KB configuration, + real model provider, actual registered MCP and ES/embedding, runtime factory and + nested Core execution. + observations: + - Sanitized public events, child runtime identity, real downloaded docx contents, + source/history hashes and cleanup. + cleanup: + - Remove only owned agents/conversations/artifacts and temporary private state. diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py new file mode 100644 index 0000000000..46faedf7a3 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py @@ -0,0 +1,285 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Real saved resources through deployed APIs; private events stay in owned files.""" + +import importlib.util +import json +import re +import sys +import time +import traceback +import zipfile +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import httpx +from database.client import get_db_session, minio_client +from database.db_models import AgentInfo, AgentRelation, ConversationRecord +from management.services.permission.identity.authentication_helpers import ( + generate_session_jwt, +) + + +def main(): + input_path, helper_path, directory = map(Path, sys.argv[1:4]) + inputs = json.loads(input_path.read_text()) + directory.mkdir(exist_ok=True) + spec = importlib.util.spec_from_file_location("owned_skill_fixture", helper_path) + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + state_path = directory / "skill-state.json" + report_path = directory / "report.json" + report = {"profile": "real_smoke", "scenarios": {}} + owned, objects, snapshots = [], set(), {} + config = runtime = None + + def fixture(mode, *args): + sys.argv = [str(helper_path), mode, str(state_path), *map(str, args)] + helper.main() + + def save(): + report_path.write_text(json.dumps(report, indent=2)) + + def publish(agent): + helper.checked(config.post(f"/agent/{agent}/publish", json={"version_name": "Resource-acceptance"})) + with get_db_session() as session: + draft = session.query(AgentInfo).filter_by(agent_id=agent, version_no=0).one() + row = session.query(AgentInfo).filter_by(agent_id=agent, version_no=draft.current_version_no).one() + return row.version_no, row.agent_core, list(row.model_ids) + + def clone(source, label, core, model, **patch): + exported = helper.export_agent(config, source) + snapshots[source] = (helper.digest(helper.source_rows(source)), helper.digest(exported)) + assert len(exported["agent_info"]) == 1 + entry = exported["agent_info"][str(source)] + entry["name"] = "resource_" + label + "_" + directory.name[-10:] + entry["display_name"] = "Resource acceptance " + label + " " + directory.name[-10:] + result = helper.checked(config.post("/agent/import", json={"agent_info": exported})) + agent = result["agent_id"] + owned.append(agent) + helper.checked(config.post("/agent/update", json={ + "agent_id": agent, "agent_core": core, "model_ids": [model], "enable_context_manager": False, + "provide_run_summary": False, "verification_config": {"enabled": False}, + "enable_protocol_repair_retry": False, **patch})) + return agent, publish(agent) + + def run(agent, version, model, query, label, conversation=None): + body = {"agent_id": agent, "version_no": version, "model_id": model, "query": query} + if conversation: + body.update(conversation_id=conversation, history=[]) + response = runtime.post("/agent/run", json=body) + assert response.status_code == 200, f"Run HTTP {response.status_code}" + conversation = conversation or int(response.headers["conversation_id"]) + events = [json.loads(line[5:].strip()) for line in response.text.splitlines() if line.startswith("data:")] + (directory / (label + ".private.json")).write_text(json.dumps(events)) + assert not [e for e in events if e.get("type") == "error"], "Run error event" + for _ in range(100): + rows = helper.history(conversation) + if rows and rows[-1]["role"] == "assistant" and rows[-1]["status"] == "completed": + break + time.sleep(.1) + assert rows[-1]["status"] == "completed", "Persisted run did not complete" + terminal = [e for e in events if e.get("type") == "run_terminal" and not e.get("invocation_id")] + final = terminal[0].get("final_content", "") if terminal else "" + if terminal: + assert len(terminal) == 1 and terminal[0]["status"] == "completed" + else: + from database.db_models import ConversationMessage + + with get_db_session() as session: + final = session.query(ConversationMessage).filter_by(message_id=rows[-1]["id"]).one().message_content + assert any(e.get("type") == "final_answer" and e.get("answer_phase") == "complete" + and not e.get("invocation_id") for e in events), "Missing root completion" + assert final and not re.search(r"completed_action_history|historical_attachments|\"']+\.docx", final) + assert refs, "No actual docx link" + bucket, key = refs[-1][5:].split("/", 1) + objects.add(key) + artifact = directory / "document.docx" + try: + success, _ = minio_client.download_file(key, str(artifact), bucket=bucket) + assert success, "Actual document download failed" + with zipfile.ZipFile(artifact) as document: + assert marker in document.read("word/document.xml").decode(), "Document content mismatch" + finally: + artifact.unlink(missing_ok=True) + + def require_successful_workspace_script(events): + calls = {e.get("local_call_id") for e in events if e.get("type") == "tool" + and e.get("tool_name") == "run_skill_script" + and (e.get("tool_arguments") or {}).get("source") == "workspace"} + results = [e for e in events if e.get("type") == "tool_result" and e.get("local_call_id") in calls] + assert any(not e.get("is_error") and not str(e.get("content", "")).startswith("[") for e in results), ( + "No successful workspace Skill script; a fallback document does not prove script capability") + + def follow(agent, version, model, conversation, marker, rows, label): + cid, events, final, later = run(agent, version, model, + "只根据本会话已完成的结果,回复上轮结果和验收编号。不要调用工具或子智能体,不要重复之前任务。", + label + "_follow", conversation) + assert cid == conversation and marker in final + assert not [e for e in events if e.get("type") in {"tool", "subagent_start"}], "Follow-up replayed resources" + assert later[:len(rows)] == rows, "Saved history changed" + + try: + source = inputs["skill_source"] + with get_db_session() as session: + draft = session.query(AgentInfo).filter_by(agent_id=source, version_no=0, delete_flag="N").one() + original = [row.conversation_id for row in session.query(ConversationRecord).filter_by( + agent_id=source, created_by=inputs["user_id"]).all()] + state = {"source": source, "user": inputs["user_id"], "source_version": draft.current_version_no, + "source_hash": helper.digest(helper.source_rows(source)), "objects": [], + "source_history": {str(cid): helper.history(cid) for cid in original}, + "access_token": generate_session_jwt(inputs["user_id"], expires_in=3600)} + headers = {"Authorization": "Bearer " + state["access_token"]} + config = httpx.Client(base_url="http://nexent-config:5010", headers=headers, trust_env=False, timeout=90) + runtime = httpx.Client(base_url="http://nexent-runtime:5014", headers=headers, trust_env=False, timeout=600) + source_skills = helper.checked(config.get("/skills/instance/list", params={ + "agent_id": source, "version_no": state["source_version"]}))["instances"] + enabled = [skill for skill in source_skills if skill.get("enabled")] + assert any(skill.get("skill_name") == "docx" for skill in enabled), "Authorized source must bind docx" + state["source_skills_hash"] = helper.digest(source_skills) + state["source_export_hash"] = helper.digest(helper.export_agent(config, source)) + child, _ = clone(source, "skill_child", "jiuwen", inputs["child_model"]) + # This clone has an explicit resource policy, without changing the saved source. + for skill in enabled: + helper.checked(config.post("/skills/instance/update", json={"agent_id": child, + "skill_id": skill["skill_id"], "enabled": True, "version_no": 0, + "config_values": skill.get("config_values") or {}})) + state["agent_id"] = child + state_path.write_text(json.dumps(state)) + owned.remove(child) + child_version, child_core, child_models = publish(child) + assert child_core == "jiuwen" and child_models[0] == inputs["child_model"] + + def skills(): + marker = "SKILLS_" + directory.name + cid, events, final, rows = run(child, child_version, inputs["child_model"], + f"帮我生成一个简单docx文件,正文包含{marker}。必须遵循docx Skill的JavaScript/docx库工作流,实际运行生成脚本,不要改用python-docx。实际生成并上传,回复文件链接和验收编号。", "skills") + names = [e.get("tool_name") for e in events if e.get("type") == "tool"] + assert "read_skill_md" in names and "nexent_execute_python" in names and "upload_to_s3" in names + assert "download_from_s3" not in names + require_successful_workspace_script(events) + verify_docx(final, marker) + follow(child, child_version, inputs["child_model"], cid, marker, rows, "skills") + return {"completed": True, "docx_verified": True, "workspace_script_verified": True, + "tool_names": names, "no_replay": True} + + def mcp(): + agent, (version, core, models) = clone(inputs["knowledge_source"], "mcp", "jiuwen", inputs["child_model"], + enabled_tool_ids=[inputs["mcp_tool_id"]], duty_prompt="Use the configured external MCP tool to answer actual current date queries.") + assert core == "jiuwen" + marker = "MCP_" + directory.name + cid, events, final, rows = run(agent, version, models[0], + f"必须调用get_current_date工具读取今天的日期,回复工具得到的日期及验收编号{marker}。", "mcp") + calls = [e for e in events if e.get("type") == "tool" and e.get("tool_name") == "get_current_date"] + assert len(calls) == 1, "External MCP call missing or repeated" + results = [e for e in events if e.get("type") == "tool_result" and + e.get("local_call_id") == calls[0].get("local_call_id")] + assert results and not any(e.get("is_error") for e in results) + today = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d") + assert today in json.dumps(results) and today in final and marker in final + follow(agent, version, models[0], cid, marker, rows, "mcp") + return {"completed": True, "date_verified": True, "external_tool_calls": 1, "no_replay": True} + + def knowledge(): + agent, (version, core, models) = clone(inputs["knowledge_source"], "knowledge", "jiuwen", inputs["child_model"]) + assert core == "jiuwen" + marker = "KB_" + directory.name + cid, events, final, rows = run(agent, version, models[0], + f"请必须检索已配置F1知识库的F1规则.docx:排位赛Q1、Q2、Q3分别决定哪些发车顺序?根据检索内容回答并注明来源文件,回复验收编号{marker}。", "knowledge") + assert any(e.get("type") == "tool" and e.get("tool_name") == "knowledge_base_search" for e in events) + evidence = [e for e in events if e.get("type") in {"search_content", "tool_result"}] + rendered = json.dumps(evidence, ensure_ascii=False) + assert "F1规则.docx" in rendered and "Q1" in rendered and "Q3" in rendered, "Actual indexed content missing" + assert all(value in final for value in [marker, "Q1", "Q2", "Q3"]) + follow(agent, version, models[0], cid, marker, rows, "knowledge") + return {"completed": True, "indexed_source_verified": True, "real_search": True, "no_replay": True} + + def mixed(): + agent, (version, core, models) = clone(inputs["knowledge_source"], "parent", "smolagents", inputs["parent_model"], + enabled_tool_ids=[], related_agents=[{"agent_id": child, "version_no": child_version}], + duty_prompt="Delegate every document generation task to the configured file generation child. Report the actual child file link and result. Never generate the document yourself.") + with get_db_session() as session: + relation = session.query(AgentRelation).filter_by(parent_agent_id=agent, version_no=version, delete_flag="N").one() + assert relation.selected_agent_id == child and relation.selected_agent_version_no == child_version + assert core == "smolagents" and models[0] != child_models[0] + marker = "MIXED_" + directory.name + cid, events, final, rows = run(agent, version, models[0], + f"请调用文件生成子智能体,实际生成并上传一个docx文件,正文包含验收编号{marker}。让子智能体遵循docx Skill的JavaScript/docx库工作流,实际运行生成脚本,不要改用python-docx。最终回复文件链接和编号。", "mixed") + starts = [e for e in events if e.get("type") == "subagent_start"] + ends = [e for e in events if e.get("type") == "subagent_end"] + assert len(starts) == len(ends) == 1 and starts[0]["invocation_id"] == ends[0]["invocation_id"] + start_payload = json.loads(starts[0]["content"]) + assert str(starts[0]["agent_id"]) == str(child) and start_payload["version_no"] == child_version + invocation = starts[0]["invocation_id"] + steps = [e for e in events if e.get("type") == "step_observation" and e.get("invocation_id") == invocation] + assert steps, "Jiuwen child public steps missing" + names = [e.get("tool_name") for e in events if e.get("type") == "tool" and e.get("invocation_id") == invocation] + assert "read_skill_md" in names and "nexent_execute_python" in names and "upload_to_s3" in names + require_successful_workspace_script(events) + verify_docx(final, marker) + follow(agent, version, models[0], cid, marker, rows, "mixed") + return {"completed": True, "parent_core": core, "child_core": child_core, "independent_models": True, + "child_docx_verified": True, "workspace_script_verified": True, + "child_tools": names, "no_replay": True} + + for label, execute in [("external_mcp", mcp), ("knowledge", knowledge), ("skills", skills), ("mixed_core", mixed)]: + try: + report["scenarios"][label] = execute() + except Exception as error: # noqa: BLE001 -- Retain each diagnostic failure and continue independent scenarios. + report["scenarios"][label] = {"completed": False, "error_type": type(error).__name__, "reason": str(error)[:180]} + (directory / (label + ".trace")).write_text(traceback.format_exc()) + save() + print("RESOURCE_SCENARIO=" + json.dumps({label: report["scenarios"][label]}), flush=True) + except Exception as error: # noqa: BLE001 -- Fixture boundary must record setup failure and run owned cleanup. + report["setup_error"] = type(error).__name__ + ": " + str(error)[:180] + (directory / "setup.trace").write_text(traceback.format_exc()) + finally: + try: + if config: + for source, (row_hash, export_hash) in snapshots.items(): + assert helper.digest(helper.source_rows(source)) == row_hash, "Original source changed" + assert helper.digest(helper.export_agent(config, source)) == export_hash, "Original bindings changed" + from database.db_models import ConversationMessage + + with get_db_session() as session: + conversations = [r.conversation_id for r in session.query(ConversationRecord).filter( + ConversationRecord.agent_id.in_(owned), ConversationRecord.created_by == inputs["user_id"]).all()] + artifact_conversations = [r.conversation_id for r in session.query(ConversationRecord).filter( + ConversationRecord.agent_id.in_([*owned, state.get("agent_id")]), + ConversationRecord.created_by == inputs["user_id"]).all()] + for message in session.query(ConversationMessage).filter( + ConversationMessage.conversation_id.in_(artifact_conversations)): + for reference in re.findall(r"s3://[^\s)\]<>\"']+", message.message_content or ""): + parts = reference[5:].split("/", 1) + if len(parts) == 2 and parts[1].startswith("workspace/" + inputs["user_id"] + "/"): + objects.add(parts[1]) + for key in objects: + helper.checked(config.delete("/file/storage/" + key)) + for cid in conversations: + helper.checked(runtime.delete(f"/conversation/{cid}")) + for agent in reversed(owned): + helper.checked(config.request("DELETE", "/agent", json={"agent_id": agent})) + if state_path.exists(): + fixture("cleanup") + report["cleanup"] = True + report["source_unchanged"] = True + except Exception as error: # noqa: BLE001 -- Cleanup failure must remain a failing acceptance result. + report["cleanup"] = False + report["cleanup_error"] = type(error).__name__ + ": " + str(error)[:180] + finally: + if config: + config.close() + runtime.close() + report["status"] = "PASS" if (len(report["scenarios"]) == 4 and + all(row["completed"] for row in report["scenarios"].values()) and report.get("cleanup")) else "FAIL" + save() + print("RESOURCE_RESULT=" + json.dumps(report), flush=True) + return report["status"] == "PASS" + + +if __name__ == "__main__": + raise SystemExit(0 if main() else 1) diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-003/execution.yaml b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/execution.yaml new file mode 100644 index 0000000000..23768f460d --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/execution.yaml @@ -0,0 +1,21 @@ +schema_version: '1.0' +case_id: JIUWEN-RESOURCES-D3-003 +implementations: +- framework: pytest + file: test_deployed.py + selector: test_deployed_resource_acceptance + profiles: + - real_smoke +timeout_seconds: 1200 +acceptance_bindings: + AC-01: + - test_deployed_resource_acceptance + AC-02: + - test_deployed_resource_acceptance + AC-03: + - test_deployed_resource_acceptance +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-003/test_deployed.py b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/test_deployed.py new file mode 100644 index 0000000000..b271a0d8b7 --- /dev/null +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/test_deployed.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Fixed real-resource smoke; machine-local identities never enter repository assets.""" + +import json +import os +import subprocess +import uuid +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.case_id("JIUWEN-RESOURCES-D3-003"), pytest.mark.stage("D3")] + + +def test_deployed_resource_acceptance(): + here = Path(__file__).resolve().parent + root = here.parents[2] + local_config = os.environ.get("NEXENT_RESOURCE_ACCEPTANCE_CONFIG") + assert local_config, "Machine-local real resource configuration is required" + inputs = json.loads(Path(local_config).read_text(encoding="utf-8")) + container = inputs.get("container", "nexent-runtime") + target = "/tmp/nexent_resources_" + uuid.uuid4().hex[:12] + result_dir = Path(os.environ["RESULT_DIR"]) / "private" + result_dir.mkdir(exist_ok=True) + for source, suffix in [(here / "deployed_resources.py", ".py"), + (root / "test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py", "_helper.py"), + (Path(local_config), "_config.json")]: + subprocess.run(["docker", "cp", str(source), container + ":" + target + suffix], + check=True, capture_output=True) + result = subprocess.run(["docker", "exec", "-e", "PYTHONPATH=/opt/backend", "-w", "/opt/backend", container, + "/opt/backend/.venv/bin/python", target + ".py", target + "_config.json", target + "_helper.py", target], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=1150, check=False) + safe = [line for line in result.stdout.splitlines() if line.startswith(("RESOURCE_SCENARIO=", "RESOURCE_RESULT="))] + print("\n".join(safe)) + # Private traces/events are needed to diagnose failures, but never print credentials/prompts. + subprocess.run(["docker", "cp", container + ":" + target + "/.", str(result_dir)], check=True, capture_output=True) + subprocess.run(["docker", "exec", container, "/opt/backend/.venv/bin/python", "-c", + "from pathlib import Path; import shutil; p=Path('" + target + "'); " + "assert p.parent==Path('/tmp') and p.name.startswith('nexent_resources_'); " + "shutil.rmtree(p); " + "[Path(str(p)+s).unlink(missing_ok=True) for s in ['.py','_helper.py','_config.json']]"], + check=True, capture_output=True) + assert result.returncode == 0, "Real resource acceptance failed; see sanitized scenarios and private local evidence" + report = json.loads(next(line.split("=", 1)[1] for line in safe if line.startswith("RESOURCE_RESULT="))) + assert report["status"] == "PASS" and report["cleanup"] and report["source_unchanged"] + assert set(report["scenarios"]) == {"external_mcp", "knowledge", "skills", "mixed_core"} + assert all(row["completed"] and row["no_replay"] for row in report["scenarios"].values()) diff --git a/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/case.yaml b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/case.yaml new file mode 100644 index 0000000000..c26c49a572 --- /dev/null +++ b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/case.yaml @@ -0,0 +1,85 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: JIUWEN-SKILL-FILES-D4-001 + feature_id: JIUWEN-RESOURCES + requirement_ids: + - JIUWEN-SAVED-SKILLS-20261021 + business_rule_ids: + - JIUWEN-RESOURCES-RULE-001 + title: Saved docx Skill assistant native file generation and history + type: E2E + priority: P1 + status: active + automation: automated + objective: Prove saved Skills authorization and actual native sandbox docx generation + with configured real models. + preconditions: + - Authorized saved file generation assistant with enabled docx Skill, configured + Qwen and DeepSeek outlets, deployed runtime/Web and Docker sandbox; source identity + in local config. + test_data: + profile: real_smoke + locale: zh + viewport: + width: 1440 + height: 1000 + steps: + - order: 1 + action: Clone source agent and explicitly copy its authorized enabled Skill bindings + and configuration; publish Jiuwen. + expected: Equivalent prompts, tools, selected outlets and docx authorization are + verified; source is unchanged. + - order: 2 + action: For each saved target model, select it in the owned configuration and + publish; request a simple docx in the browser, then follow up without tools. + expected: Actual read_skill_md and controlled Python create and upload a downloadable + docx containing a fresh marker; no guessed Skill downloads or run errors. Follow-up + recalls the marker without resource execution. + - order: 3 + action: Reload each completed conversation from its persisted history and verify + original messages. + expected: One common rendered final per turn, correct file links and marker; no + internal envelopes, duplicate answers or run request on history load. + expected_results: + - Both saved real outlets complete Skill-guided native docx generation; downloaded + word/document.xml contains the fresh marker. + - Multi-turn follow-up and restored history retain facts without replay, clean output + and common Nexent rendering. + - Fixture cleanup removes only owned agents/conversations/artifacts; original saved + configuration, bindings and source conversation remain unchanged. + forbidden_side_effects: + - No invented or repeated Skill S3 downloads, permission widening, alternate host + shell, injected model/tool results, fabricated success or runtime Core override. + - No source mutation, leaked credentials, tool replay or run request on history + load; no dedicated Jiuwen renderer or internal protocol/history text. + actor: Authorized Agent development user + journey: + - Clone saved Skill assistant, generate a docx with both real models, follow up + and reload history. + final_business_result: + - Actual downloadable document and correct shared browser/history rendering with + enabled docx Skill. + failure_evidence: + - Fixed browser assertions, sanitized tool names and terminal summaries, downloaded + document content check and source/owned history hashes. + acceptance: + - id: AC-01 + description: Saved Skills and actual native documents on real model outlets. + steps: + - 1 + - 2 + expected_results: + - 1 + forbidden_side_effects: + - 1 + - id: AC-02 + description: No replay, clean common history and isolated fixture cleanup. + steps: + - 3 + expected_results: + - 2 + - 3 + forbidden_side_effects: + - 2 diff --git a/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/execution.yaml b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/execution.yaml new file mode 100644 index 0000000000..e4746a8e9a --- /dev/null +++ b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/execution.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' +case_id: JIUWEN-SKILL-FILES-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: JIUWEN-SKILL-FILES-D4-001 + profiles: + - real_smoke +timeout_seconds: 1500 +acceptance_bindings: + AC-01: + - JIUWEN-SKILL-FILES-D4-001 + AC-02: + - JIUWEN-SKILL-FILES-D4-001 +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py new file mode 100644 index 0000000000..dc5b77ab34 --- /dev/null +++ b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py @@ -0,0 +1,169 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Clone authorized saved Skill bindings; keep all private state outside Git.""" + +import hashlib +import io +import json +import re +import sys +import time +import zipfile +from pathlib import Path + +import httpx +from database.client import get_db_session, minio_client +from database.db_models import AgentInfo, ConversationMessage, ConversationRecord +from management.services.permission.identity.authentication_helpers import generate_session_jwt + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest() + + +def source_rows(source): + with get_db_session() as session: + return [{c.name: getattr(row, c.name) for c in AgentInfo.__table__.columns} + for row in session.query(AgentInfo).filter_by(agent_id=source).order_by(AgentInfo.version_no)] + + +def checked(response): + assert response.status_code == 200, f"API {response.status_code} at {response.request.url.path}" + return response.json() + + +def export_agent(config, agent_id): + response = config.post("/agent/export", json={"agent_id": agent_id}) + assert response.status_code == 200 + if response.headers.get("content-type", "").startswith("application/zip"): + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + return json.loads(archive.read("agent.json")) + return response.json()["data"] + + +def history(conversation): + with get_db_session() as session: + return [{"id": row.message_id, "index": row.message_index, "role": row.message_role, + "status": row.status, "hash": digest(row.message_content)} + for row in session.query(ConversationMessage).filter_by(conversation_id=conversation).order_by( + ConversationMessage.message_index, ConversationMessage.message_id)] + + +def main(): + mode, filename = sys.argv[1:3] + path = Path(filename) + if mode == "setup": + source, user = int(sys.argv[3]), sys.argv[4] + with get_db_session() as session: + draft = session.query(AgentInfo).filter_by(agent_id=source, version_no=0, delete_flag="N").one() + published = session.query(AgentInfo).filter_by(agent_id=source, version_no=draft.current_version_no).one() + assert published.agent_core == "jiuwen" + originals = session.query(ConversationRecord).filter_by(agent_id=source, created_by=user).all() + conversations = {row.conversation_id: history(row.conversation_id) for row in originals} + keys = ["model_ids", "business_logic_model_id", "duty_prompt", + "enable_context_manager", "max_steps", "provide_run_summary", "verification_config", + "enable_protocol_repair_retry", "requested_output_tokens", "context_policy", "model_params_override"] + fields = {key: getattr(published, key) for key in keys} + state = {"source": source, "user": user, "tenant": published.tenant_id, + "source_hash": digest(source_rows(source)), "source_history": conversations, + "source_version": published.version_no, "fields": fields, + "access_token": generate_session_jwt(user, expires_in=3600), + "expires_at": int(time.time()) + 3600, "objects": []} + path.write_text(json.dumps(state, default=str)) + else: + state = json.loads(path.read_text()) + headers = {"Authorization": "Bearer " + state["access_token"]} + with httpx.Client(base_url="http://nexent-config:5010", headers=headers, trust_env=False, timeout=90) as config, \ + httpx.Client(base_url="http://nexent-runtime:5014", headers=headers, trust_env=False, timeout=90) as runtime: + if mode == "setup": + exported = export_agent(config, state["source"]) + assert len(exported["agent_info"]) == 1, "Fixture expects standalone file generator" + state["source_export_hash"] = digest(exported) + skills = checked(config.get("/skills/instance/list", params={ + "agent_id": state["source"], "version_no": state["source_version"]}))["instances"] + enabled = [s for s in skills if s.get("enabled")] + assert any(s.get("skill_name") == "docx" for s in enabled), "Source must bind docx Skill" + state["source_skills_hash"] = digest(skills) + entry = exported["agent_info"][str(state["source"])] + entry["name"] = "skill_files_" + path.stem[-12:] + entry["display_name"] = "鏂囦欢鐢熸垚鍔╂墜-Skill楠屾敹-" + path.stem[-12:] + result = checked(config.post("/agent/import", json={"agent_info": exported})) + state["agent_id"] = result["agent_id"] + path.write_text(json.dumps(state, default=str)) + checked(config.post("/agent/update", json={"agent_id": state["agent_id"], **state["fields"], + "agent_core": "jiuwen"})) + for skill in enabled: + checked(config.post("/skills/instance/update", json={"agent_id": state["agent_id"], + "skill_id": skill["skill_id"], "enabled": True, "version_no": 0, + "config_values": skill.get("config_values") or {}})) + cloned = export_agent(config, state["agent_id"]) + clone = cloned["agent_info"][str(state["agent_id"])] + for key in ["model_ids", "duty_prompt", "skill_names"]: + assert clone[key] == entry[key], "Clone mismatch: " + key + left = [{k: v for k, v in t.items() if k != "agent_id"} for t in entry["tools"]] + right = [{k: v for k, v in t.items() if k != "agent_id"} for t in clone["tools"]] + assert sorted(left, key=str) == sorted(right, key=str), "Clone tool mismatch" + checked(config.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Skill-files-real"})) + print(json.dumps({"setup": True, "docx_skill": True, "equivalent_configuration": True})) + elif mode == "select-model": + model = int(sys.argv[3]) + assert model in state["fields"]["model_ids"] + checked(config.post("/agent/update", json={"agent_id": state["agent_id"], + "model_ids": [model] + [m for m in state["fields"]["model_ids"] if m != model]})) + checked(config.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Skill-files-" + str(model)})) + print(json.dumps({"selected": model})) + elif mode in {"check", "verify-docx"}: + conversation = int(sys.argv[3]) + with get_db_session() as session: + owner = session.query(ConversationRecord).filter_by(conversation_id=conversation).one() + assert owner.agent_id == state["agent_id"] and owner.created_by == state["user"] + latest = session.query(ConversationMessage).filter_by( + conversation_id=conversation, message_role="assistant").order_by( + ConversationMessage.message_id.desc()).first() + content = latest.message_content + if mode == "check": + print(json.dumps({"messages": history(conversation)})) + else: + refs = re.findall(r"s3://[^\s)\]<>\"']+\.docx", content) + assert refs, "Missing actual docx link" + bucket, key = refs[-1][5:].split("/", 1) + state["objects"] = list(set([*state["objects"], key])) + path.write_text(json.dumps(state, default=str)) + artifact = path.with_suffix(".docx") + try: + success, _ = minio_client.download_file(key, str(artifact), bucket=bucket) + assert success + with zipfile.ZipFile(artifact) as document: + assert sys.argv[4] in document.read("word/document.xml").decode() + finally: + artifact.unlink(missing_ok=True) + print(json.dumps({"downloaded_docx_verified": True})) + elif mode == "cleanup": + assert digest(source_rows(state["source"])) == state["source_hash"], "Source agent changed" + for conversation, rows in state["source_history"].items(): + assert history(int(conversation)) == rows, "Source history changed" + current = checked(config.get("/skills/instance/list", params={ + "agent_id": state["source"], "version_no": state["source_version"]}))["instances"] + assert digest(current) == state["source_skills_hash"], "Source Skill bindings changed" + assert digest(export_agent(config, state["source"])) == state["source_export_hash"] + with get_db_session() as session: + ids = [r.conversation_id for r in session.query(ConversationRecord).filter_by( + agent_id=state.get("agent_id"), created_by=state["user"]).all()] if state.get("agent_id") else [] + for key in state["objects"]: + checked(config.delete("/file/storage/" + key)) + for conversation in ids: + checked(runtime.delete(f"/conversation/{conversation}")) + if state.get("agent_id"): + checked(config.request("DELETE", "/agent", json={"agent_id": state["agent_id"]})) + path.unlink(missing_ok=True) + print(json.dumps({"cleanup": True, "source_unchanged": True, "conversations": len(ids)})) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + import traceback + + Path(sys.argv[2] + ".trace").write_text(traceback.format_exc()) + print("FIXTURE_FAILURE=" + type(error).__name__ + ": " + str(error)[:180]) + raise SystemExit(1) diff --git a/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/test.spec.ts b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/test.spec.ts new file mode 100644 index 0000000000..d6e3b6c8d4 --- /dev/null +++ b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/test.spec.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +import { journey } from "../../infra/automation/d4/runner/journey"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +journey("JIUWEN-SKILL-FILES-D4-001", async ({ page, contract, expect }) => { + const input = JSON.parse(readFileSync(process.env.NEXENT_SKILL_FILES_CONFIG!, "utf8")); + const base = process.env.NEXENT_BASE_URL || "http://127.0.0.1:3000"; + const container = input.container || "nexent-runtime"; + const unique = randomUUID().replaceAll("-", "").slice(0, 12); + const target = `/tmp/nexent_skill_files_${unique}`; + const local = join(contract.caseDir, "private-state.json"); + const outcomes: any[] = []; + let state: any; + let runCount = 0; + const docker = (...args: string[]) => { + try { return execFileSync("docker", args, {encoding:"utf8",timeout:120000,stdio:["ignore","pipe","pipe"]}); } + catch (error) { throw new Error(String((error as any).stdout || "").split(/\r?\n/).find(l=>l.startsWith("FIXTURE_FAILURE=")) || "Owned fixture command failed"); } + }; + const fixture = (mode: string, ...args: string[]) => JSON.parse(docker("exec", "-e", "PYTHONPATH=/opt/backend", + "-w", "/opt/backend", container, "/opt/backend/.venv/bin/python", target+".py", mode, target+".json", ...args).trim().split(/\r?\n/).at(-1)!); + const save = () => writeFileSync(join(contract.caseDir,"real-outcomes.json"),JSON.stringify(outcomes,null,2)); + docker("cp", join(process.env.NEXENT_REPO!,"test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py"),container+":"+target+".py"); + contract.deferCleanup(async () => { + try { if (!state?.cleaned) { const result=fixture("cleanup");outcomes.push(result);save();expect(result.source_unchanged).toBe(true); } } + finally { if(existsSync(local))unlinkSync(local);docker("exec",container,"/opt/backend/.venv/bin/python","-c",`from pathlib import Path; Path('${target}.py').unlink(missing_ok=True)`); } + }); + page.on("request",r=>{if(r.method()==="POST"&&new URL(r.url()).pathname==="/api/agent/run")runCount++;}); + await contract.precondition(contract.expectedIds("preconditions")[0],async()=>{ + const result=fixture("setup",String(input.source_agent_id),input.user_id); + docker("cp",container+":"+target+".json",local);state=JSON.parse(readFileSync(local,"utf8")); + await page.context().addCookies([ + {name:"nexent_access_token",value:state.access_token,url:base,httpOnly:true,sameSite:"Lax",expires:state.expires_at}, + {name:"nexent_token_expires_at",value:String(state.expires_at),url:base,sameSite:"Lax",expires:state.expires_at}]); + outcomes.push(result);save();return "Authorized docx Skill source cloned with equivalent configuration."; + }); + const roots=()=>page.locator('[data-slot="aui_assistant-message-root"]'); + const finals=(row:any)=>row.locator(".aui-md").evaluateAll((nodes:Element[])=>nodes + .filter(n=>!n.closest(".aui-reasoning-root")&&!n.closest("[data-subagent-run-id]")) + .map(n=>(n as HTMLElement).innerText)); + const send=async(query:string,model:number)=>{ + await page.getByRole("textbox").last().fill(query); + const wire=page.waitForResponse(r=>r.request().method()==="POST"&&new URL(r.url()).pathname==="/api/agent/run"); + await page.getByRole("button",{name:"发送",exact:true}).click(); + const response=await wire;expect(response.status()).toBe(200); + const request=response.request().postDataJSON();expect(request.agent_id).toBe(state.agent_id); + expect(request.agent_core).toBeUndefined();expect(request.model_id).toBe(model); + const conversation=request.conversation_id??Number(response.headers()["conversation_id"]); + const events=(await response.body()).toString("utf8").split(/\r?\n/).filter(l=>l.startsWith("data:")).map(l=>JSON.parse(l.slice(5).trim())); + expect(events.filter(e=>e.type==="error")).toEqual([]); + const terminal=events.filter(e=>e.type==="run_terminal"&&!e.invocation_id); + expect(terminal).toHaveLength(1);expect(terminal[0].status).toBe("completed"); + expect(terminal[0].final_content).not.toMatch(/completed_action_history|historical_attachments|<\/?final_answer\b/); + await expect(roots().last().locator('[data-slot="aui_assistant-message-completion-indicator"]')).toBeVisible({timeout:30000}); + return {conversation,events,final:terminal[0].final_content}; + }; + const completed:any[]=[]; + const steps=contract.expectedIds("steps"); + await contract.step(steps[0],async()=>{expect(outcomes[0].docx_skill).toBe(true);expect(outcomes[0].equivalent_configuration).toBe(true);}); + await contract.step(steps[1],async()=>{ + for(const model of input.model_ids){ + fixture("select-model",String(model)); + await page.goto(`${base}/zh/newchat?agent_id=${state.agent_id}`); + await expect(page.getByRole("textbox").last()).toBeVisible(); + const marker="SKILL_DOCX_"+unique+"_"+model; + const generated=await send(`帮我随便生成一个docx文件,简单写点内容就行。文件名为 skill_${model}.docx,正文包含验收编号${marker}。请实际生成并上传,最终回复下载链接和编号。`,model); + const calls=generated.events.filter(e=>e.type==="tool"); + expect(calls.some(e=>e.tool_name==="read_skill_md")).toBe(true); + expect(calls.some(e=>e.tool_name==="nexent_execute_python")).toBe(true); + expect(calls.filter(e=>e.tool_name==="download_from_s3")).toHaveLength(0); + expect(generated.final).toContain(marker);expect(generated.final).toMatch(/s3:\/\/[^\s)]+\.docx/); + expect(fixture("verify-docx",String(generated.conversation),marker).downloaded_docx_verified).toBe(true); + let before:any; + await expect.poll(()=>{before=fixture("check",String(generated.conversation));return before.messages.filter((m:any)=>m.role==="assistant"&&m.status==="completed").length;},{timeout:30000}).toBe(1); + await page.goto(`${base}/zh/newchat?thread_id=${generated.conversation}`); + await expect(roots()).toHaveCount(1); + const follow=await send("只根据本会话已完成的任务,回复生成的文件名和验收编号。不要调用工具,不要重新生成或上传文件。",model); + expect(follow.conversation).toBe(generated.conversation);expect(follow.final).toContain(marker); + expect(follow.events.filter(e=>e.type==="tool")).toHaveLength(0); + let after:any; + await expect.poll(()=>{after=fixture("check",String(generated.conversation));return after.messages.filter((m:any)=>m.role==="assistant"&&m.status==="completed").length;},{timeout:30000}).toBe(2); + expect(after.messages.slice(0,2)).toEqual(before.messages); + completed.push({model,conversation:generated.conversation,marker,history:after.messages}); + outcomes.push({model,downloaded_docx_verified:true,no_skill_download:true,no_followup_replay:true,tool_names:calls.map(e=>e.tool_name)});save(); + } + }); + await contract.step(steps[2],async()=>{ + for(const item of completed){ + const before=runCount; + await page.goto(`${base}/zh/newchat?thread_id=${item.conversation}`);await expect(roots()).toHaveCount(2); + for(const row of await roots().all()){ + await expect.poll(async()=> (await finals(row)).some((s:string)=>s.includes(item.marker))).toBe(true); + const texts=await finals(row);expect(texts).toHaveLength(1);expect(texts[0]).toContain(item.marker); + expect(texts[0]).not.toMatch(/completed_action_history|historical_attachments|<\/?final_answer\b/); + } + expect(runCount).toBe(before);expect(fixture("check",String(item.conversation)).messages).toEqual(item.history); + await expect(page.locator("[data-runtime-output]")).toHaveCount(0); + } + outcomes.push({history_reload:true,no_run_on_load:true,common_renderer:true});save(); + }); + const assertions=contract.expectedIds("assertions"); + await contract.assertion(assertions[0],async()=>{expect(completed).toHaveLength(input.model_ids.length);}); + await contract.assertion(assertions[1],async()=>{expect(runCount).toBe(input.model_ids.length*2);expect(outcomes.at(-1).history_reload).toBe(true);}); + await contract.assertion(assertions[2],async()=>{const result=fixture("cleanup");expect(result.source_unchanged).toBe(true);outcomes.push(result);save(); + state.cleaned=true;}); +}); diff --git a/test-e2e/changes/bugs/JIUWEN-MCP-NAMES-20261021.yaml b/test-e2e/changes/bugs/JIUWEN-MCP-NAMES-20261021.yaml new file mode 100644 index 0000000000..2c922566e2 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-MCP-NAMES-20261021.yaml @@ -0,0 +1,48 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-MCP-NAMES-20261021 + change_type: bugfix + bug_id: JIUWEN-MCP-NAMES-20261021 + title: Resolve registered MCP normalized names to original remote tool names + description: Index unambiguous native MCP tools by both raw and registration-compatible + normalized names. + observed_behavior: Registered get_current_date cannot match real get-current-date + on healthy external MCP and Agent fails before execution. + expected_behavior: Exact or normalized authorized names map to original wire names; + collisions and missing tools fail closed. + escaped_stage: D3 + coverage_gap: + type: missing_assertion + description: Controlled MCP fixtures used identical raw and saved class names; + real external registration-normalization boundary was unproved. + feature_contract_changed: false + affected_features: + existing: + - JIUWEN-RESOURCES + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-RESOURCES-D3-003 + - JIUWEN-RESOURCES-D5-001 + modified: + - JIUWEN-RESOURCES-D3-001 + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Original and registration-compatible normalized names resolve to original wire + calls and saved public names. + - Ambiguous normalized names, duplicate original names and missing unauthorized + tools reject before execution; session resources close. + reason: Unit collision and normalization boundaries supplement real protocol and + deployed external MCP proof. + test_selectors: + - test/sdk/core/agents/test_jiuwen_mcp_names.py + notes: runtime-agent-core-factory design.md 2026-10-21 confirmed MCP defect. No + new D1; existing D3 resource fixture gains real hyphenated raw tool name; real + D3-003 and mixed Core/cancellation regression reused. Preserve earlier unrelated + user modifications and global F-111 rule validation blocker. diff --git a/test-e2e/changes/bugs/JIUWEN-SAVED-SKILLS-20261021.yaml b/test-e2e/changes/bugs/JIUWEN-SAVED-SKILLS-20261021.yaml new file mode 100644 index 0000000000..2633dd99b0 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-SAVED-SKILLS-20261021.yaml @@ -0,0 +1,54 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-SAVED-SKILLS-20261021 + change_type: bugfix + bug_id: JIUWEN-SAVED-SKILLS-20261021 + title: Preserve saved-agent skill authorization and native sandbox file generation + description: Pass explicit Skills snapshots to every skill tool and expose controlled + Python in native Jiuwen mode. + observed_behavior: Saved docx Skill bindings fail preparation; without native file + creation the model guesses nonexistent S3 skill paths. + expected_behavior: Authorized saved Skill tools prepare, native Python creates and + uploads actual files through the configured sandbox, follow-ups never replay tasks. + escaped_stage: D4 + coverage_gap: + type: missing_boundary + description: Existing UT checked only script authorization; resource integration + did not exercise native Python file creation or saved Skill initialization. + feature_contract_changed: true + affected_features: + existing: [] + modified: + - JIUWEN-RESOURCES + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D5-001 + modified: + - JIUWEN-RESOURCES-D3-001 + added: + - JIUWEN-SKILL-FILES-D4-001 + retired: [] + traditional_ut: + decision: add + behaviors: + - All three skill tools receive detached saved/transient authorization and preserve + tenant/version; empty snapshot cannot inherit saved skills. + - Native sandbox entry uses configured executor, authorized bindings and managed + lane; schemas and reserved names fail closed. + reason: Unit edge/error contracts supplement real Runner and real-provider file + generation boundaries. + test_selectors: + - test/backend/agents/test_create_agent_info.py::TestGetSkillScriptTools + - test/sdk/core/agents/test_jiuwen_native_files.py + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/tools/test_workbench_skill_readers.py + notes: Existing runtime-agent-core-factory SPEC 2026-10-21 AC-SK-01..04. Strengthen + resource D3 with native sandbox write; reuse D5 cleanup. Real saved docx-agent + journey needs owned source clone and artifact/history assertions. No new D1; unrelated + F-111-RULE-02 gate blocker retained. New direct saved-Skill browser D4 proves + a distinct standalone published-agent authorization boundary; old image/child + D4 cannot represent a source file generator without image tools. Prepared D4 is + blocked by unrelated global validation; direct fixed-journey diagnostic execution + is recorded separately. diff --git a/test-e2e/changes/test-fixes/JIUWEN-RESOURCE-ACCEPTANCE-20261021.yaml b/test-e2e/changes/test-fixes/JIUWEN-RESOURCE-ACCEPTANCE-20261021.yaml new file mode 100644 index 0000000000..0274b36670 --- /dev/null +++ b/test-e2e/changes/test-fixes/JIUWEN-RESOURCE-ACCEPTANCE-20261021.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-RESOURCE-ACCEPTANCE-20261021 + change_type: test-fix + title: Verify deployed Skills external MCP knowledge and mixed Core delegation + description: Add real deployed configuration/resource boundary evidence; reuse controlled + resource and collaboration cases. + affected_features: + existing: + - JIUWEN-RESOURCES + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D3-002 + modified: [] + added: + - JIUWEN-RESOURCES-D3-003 + retired: [] + notes: runtime-agent-core-factory design.md 2026-10-21 four-resource test-backfill. + Existing UT owns unit boundaries and existing D3 uses controlled endpoints; missing + proof is deployed real configured resources. No new D1 or UI change. If actual + product defect is reproduced, route a separate bug Change and preserve these acceptance + assertions. Stronger verification found lightweight sandbox lacks Node; final + acceptance requires full sandbox and actual successful workspace JS Skill execution, + not Python fallback. No product sandbox restriction is weakened. diff --git a/test-e2e/features/JIUWEN-RESOURCES/feature.yaml b/test-e2e/features/JIUWEN-RESOURCES/feature.yaml index 07a00aebb8..2219ded5ce 100644 --- a/test-e2e/features/JIUWEN-RESOURCES/feature.yaml +++ b/test-e2e/features/JIUWEN-RESOURCES/feature.yaml @@ -11,7 +11,9 @@ feature: business_rules: - rule_id: JIUWEN-RESOURCES-RULE-001 description: Native and text calls share authorized tool/MCP/Skills execution, - identity, observations and cleanup. + identity, observations and cleanup. Saved and transient agents pass explicit + per-agent Skill snapshots; native controlled Python uses the configured sandbox + to generate files without guessed Skill downloads. - rule_id: JIUWEN-RESOURCES-RULE-002 description: Core selection preserves saved policies and resource bindings; unsupported capabilities fail explicitly. @@ -25,7 +27,9 @@ feature: required_test_stages: - D3 - D5 + - D4 source: requirement_ids: - JIUWEN-M3-20261015 + - JIUWEN-SAVED-SKILLS-20261021 spec_path: ../nexent-doc/docs/Developing/runtime-agent-core-factory/design.md diff --git a/test-e2e/infra/automation/jiuwen_resource_provider.py b/test-e2e/infra/automation/jiuwen_resource_provider.py index b1b862cc2e..8899df37b9 100644 --- a/test-e2e/infra/automation/jiuwen_resource_provider.py +++ b/test-e2e/infra/automation/jiuwen_resource_provider.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Local deterministic OpenAI-compatible model and native MCP fixture.""" import asyncio @@ -24,7 +26,7 @@ mcp = FastMCP("jiuwen-local-fixture") -@mcp.tool +@mcp.tool(name="multiply-values") async def multiply(a: int, b: int, user_id: str | None = None) -> int: state["mcp_calls"] += 1 state["user_id"] = user_id @@ -63,6 +65,12 @@ async def completions(request: Request): ("read_skill_md", {"skill_name": "fixture_skill"}), ("run_skill_script", {"skill_name": "fixture_skill", "script_path": "scripts/hello.py"}), ] + if "NATIVE_FILE_MARKER" in json.dumps(messages): + chain.append(("nexent_execute_python", {"code": ( + "from pathlib import Path\n" + "Path('native-created.txt').write_text('NATIVE_SANDBOX_FILE_MARKER')\n" + "print('NATIVE_SANDBOX_FILE_MARKER')" + )})) text_mode = not body.get("tools") if "BLOCK_MCP_MARKER" in json.dumps(messages): chain = [("multiply", {"a": 999, "b": 4})] diff --git a/test-e2e/infra/automation/jiuwen_resource_runtime.py b/test-e2e/infra/automation/jiuwen_resource_runtime.py index afc9c74a12..5c0b4591b1 100644 --- a/test-e2e/infra/automation/jiuwen_resource_runtime.py +++ b/test-e2e/infra/automation/jiuwen_resource_runtime.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Real native/text resource flows through managed workers and local protocol servers.""" import asyncio @@ -27,6 +29,29 @@ from nexent.core.utils.observer import MessageObserver +def read_workspace_evidence(path): + """Inspect real outputs through Docker when Windows denies Linux-created file ACLs.""" + if not path.exists(): + return "" + try: + return path.read_text() + except PermissionError: + if sys.platform != "win32": + raise + import docker + + client = docker.from_env() + try: + return client.containers.run( + "nexent/nexent-sandbox-full:latest", + ["-c", "from pathlib import Path; print(Path('/evidence/" + path.name + "').read_text(), end='')"], + entrypoint="python", user="0", network_disabled=True, remove=True, + volumes={str(path.parent): {"bind": "/evidence", "mode": "ro"}}, + ).decode() + finally: + client.close() + + @contextmanager def resource_provider(tmp_path): with socket.socket() as sock: @@ -101,7 +126,8 @@ def track_close(adapter): (skill / "scripts/hello.py").write_text( f"from pathlib import Path\nimport os\np=Path(os.environ.get('NEXENT_OUTPUT_DIR', {str(effect.parent)!r}))/" "'script-effects.txt'\n" - "p.write_text(p.read_text()+'x' if p.exists() else 'x')\nprint('SCRIPT_RESULT_MARKER')\n", encoding="utf-8", + "p.write_text(p.read_text()+'x' if p.exists() else 'x')\nprint('SCRIPT_RESULT_MARKER')\n", + encoding="utf-8", ) hidden = skill.parent / "forbidden_skill" hidden.mkdir(exist_ok=True) @@ -117,7 +143,7 @@ def track_close(adapter): network_disabled=False, timeout_seconds=30, workspace_mode="bind", container_workspace_root="/work", failure_policy="error", extra_kwargs={"workspace_root": str(tmp_path)}) tools = [ToolConfig(name="add", class_name="ResourceAddTool", source="local", params={}), - ToolConfig(name="multiply", class_name="multiply", source="mcp", params={"b": 4}), + ToolConfig(name="multiply", class_name="multiply_values", source="mcp", params={"b": 4}), ToolConfig(name="read_skill_md", class_name="ReadSkillMdTool", source="builtin", params=parameters), ToolConfig(name="run_skill_script", class_name="RunSkillScriptTool", source="builtin", params=parameters)] if scenario == "parallel": @@ -130,7 +156,8 @@ def track_close(adapter): profile = profile_for(url, protocol) info = AgentRunInfo( query="Run the authorized resource chain" + (" BLOCK_MCP_MARKER" if scenario in {"timeout", "cancel"} - else " PARALLEL_MARKER" if scenario == "parallel" else ""), + else " PARALLEL_MARKER" if scenario == "parallel" else "") + + (" NATIVE_FILE_MARKER" if docker_sandbox and protocol == "native" else ""), observer=MessageObserver(lang="en"), stop_event=threading.Event(), thread_manager=manager, tenant_id="spike-tenant", user_context={"user_id": "captured-user"}, sandbox_config=sandbox, workspace_path=str(tmp_path / "run") if docker_sandbox else None, @@ -165,7 +192,10 @@ async def cancel_when_mcp_started(): state = (await client.get(url + "/state")).json() if scenario in {"normal", "parallel"}: assert info.attempt_outcome == "completed", output[-1:] - previous = (len(calls), state["mcp_calls"], effect.read_text() if effect.exists() else "") + if docker_sandbox and protocol == "native": + artifact = tmp_path / "run/outputs/native-created.txt" + assert read_workspace_evidence(artifact) == "NATIVE_SANDBOX_FILE_MARKER" + previous = (len(calls), state["mcp_calls"], read_workspace_evidence(effect)) follow_up = info.model_copy(update={ "query": "What can you do? CURRENT_TASK_ONLY_MARKER", "observer": MessageObserver(lang="en"), "stop_event": threading.Event(), "cancellation_scope": None, @@ -177,8 +207,8 @@ async def cancel_when_mcp_started(): assert any("CURRENT_RESPONSE_MARKER" in str(event) for event in second) async with httpx.AsyncClient(trust_env=False) as client: later = (await client.get(url + "/state")).json() - assert previous == (len(calls), later["mcp_calls"], effect.read_text() if effect.exists() else "") - return info, state, output, calls, effect.read_text() if effect.exists() else "", elapsed + assert previous == (len(calls), later["mcp_calls"], read_workspace_evidence(effect)) + return info, state, output, calls, read_workspace_evidence(effect), elapsed finally: if canceller and not canceller.done(): canceller.cancel() diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 074ae4164e..6190df05ab 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -877,6 +877,9 @@ def test_ut_be_fps_010_injects_skill_tools_per_agent_without_write_tool(self): assert "write_skill_file" not in child_names run_skill_call = mock_tool_config.call_args_list[0] assert run_skill_call.kwargs["params"]["authorized_skill_names"] == ["analysis"] + for call in mock_tool_config.call_args_list[:3]: + assert call.kwargs["params"]["authorized_skill_names"] == ["analysis"] + assert call.kwargs["metadata"] == {"agent_id": 2, "tenant_id": "tenant_1", "version_no": 0} from nexent.core.prompts import load_prompt assert run_skill_call.kwargs["description"] == load_prompt( @@ -1030,6 +1033,24 @@ def test_get_skill_script_tools_injects_runtime_file_context(self): assert call[1]["metadata"] == file_context assert call[1]["params"]["workspace_path"] == file_context["workspace_path"] + @pytest.mark.parametrize("snapshot", [None, [{"name": "test_skill", "config_values": {}}]]) + def test_all_skill_tools_receive_detached_authorization_with_empty_configuration(self, snapshot): + mock_tool_config.reset_mock() + _get_skill_script_tools(42, "tenant", version_no=13, runtime_skill_snapshot=snapshot) + calls = mock_tool_config.call_args_list[:3] + for call in calls: + assert call.kwargs["params"]["authorized_skill_names"] == ["test_skill"] + assert call.kwargs["metadata"] == {"agent_id": 42, "tenant_id": "tenant", "version_no": 13} + calls[0].kwargs["params"]["authorized_skill_names"].append("not_authorized") + assert calls[1].kwargs["params"]["authorized_skill_names"] == ["test_skill"] + assert calls[2].kwargs["params"]["authorized_skill_names"] == ["test_skill"] + + def test_explicit_empty_snapshot_does_not_use_saved_skill_bindings(self): + mock_tool_config.reset_mock() + _get_skill_script_tools(42, "tenant", runtime_skill_snapshot=[]) + assert [call.kwargs["name"] for call in mock_tool_config.call_args_list] == [ + "download_from_s3", "upload_to_s3"] + class TestDiscoverLangchainTools: """Tests for the discover_langchain_tools function""" diff --git a/test/sdk/core/agents/test_jiuwen_history.py b/test/sdk/core/agents/test_jiuwen_history.py index 62587a360f..c8af070e85 100644 --- a/test/sdk/core/agents/test_jiuwen_history.py +++ b/test/sdk/core/agents/test_jiuwen_history.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Regression coverage for history replay at the native model boundary.""" import sys @@ -137,6 +139,8 @@ def test_native_prompt_and_retry_configuration(monkeypatch, saved_profile): adapter = adapter_with_history([AgentHistory(role="user", content="Analyze an old image")], saved_profile) adapter.run_info.thread_manager = SimpleNamespace() monkeypatch.setattr(adapter, "_skill_rails", list) + bind_executor = Mock() + monkeypatch.setattr(adapter, "_bind_text_executor", bind_executor) monkeypatch.setattr(jiuwen, "NexentAgent", lambda **kwargs: SimpleNamespace( _prepare_file_workspace=lambda query: query, )) @@ -150,6 +154,7 @@ def test_native_prompt_and_retry_configuration(monkeypatch, saved_profile): retry_module.ModelAnomalyDetectionRail = retry_factory monkeypatch.setitem(sys.modules, retry_module.__name__, retry_module) adapter.prepare() + bind_executor.assert_called_once_with() options = factory.call_args.kwargs assert "only the latest user request" in options["system_prompt"] assert "not pending tasks" in options["system_prompt"] diff --git a/test/sdk/core/agents/test_jiuwen_mcp_names.py b/test/sdk/core/agents/test_jiuwen_mcp_names.py new file mode 100644 index 0000000000..51f794c043 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_mcp_names.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Registered MCP names resolve without changing wire names or guessing collisions.""" + +from unittest.mock import AsyncMock, Mock + +import pytest +from nexent.core.agents.agent_model import ToolConfig +from nexent.core.agents.core_adapters import mcp +from nexent.core.concurrency import RunCancellationScope +from openjiuwen.core.foundation.tool.mcp.base import McpToolCard + + +@pytest.mark.asyncio +@pytest.mark.parametrize("raw,saved", [ + ("get-current-date", "get_current_date"), ("get-current-date", "get-current-date"), + ("tools.date", "toolsdate"), ("123date", "_123date"), ("class", "class_"), ("date", "date"), +]) +async def test_registered_name_maps_to_original_wire_and_saved_public_alias(monkeypatch, raw, saved): + client = Mock(connect=AsyncMock(return_value=True), disconnect=AsyncMock(), list_tools=AsyncMock(return_value=[ + McpToolCard(name=raw, server_name="fixture", input_params={"type": "object", "properties": {}})])) + monkeypatch.setattr(mcp, "_ConfiguredNativeClient", Mock(return_value=client)) + proxies = [] + original = mcp._ManagedNativeClient + + def track(*args, **kwargs): + proxy = original(*args, **kwargs) + proxies.append(proxy) + return proxy + + monkeypatch.setattr(mcp, "_ManagedNativeClient", track) + owner = mcp.JiuwenMCPCollection(manager=Mock(), cancellation_scope=RunCancellationScope(), + server_parameters=["http://fixture/mcp"], tool_timeout_seconds=1, close_timeout_seconds=1, + authorized_tools=[ToolConfig(name="public_date", class_name=saved, source="mcp")], observe=Mock()) + owner._close_requested.set() + await owner._run_async_session() + assert owner.tools[0].card.name == "public_date" + assert len(proxies) == 1 and proxies[0].remote_name == raw + client.disconnect.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("names,saved", [ + (["get-date", "get_date"], "get_date"), + (["get.date", "getdate"], "getdate"), + (["get-date", "get-date"], "get-date"), + (["allowed"], "not_authorized"), +]) +async def test_missing_or_ambiguous_authorized_names_fail_before_dispatch(monkeypatch, names, saved): + client = Mock(connect=AsyncMock(return_value=True), disconnect=AsyncMock(), list_tools=AsyncMock(return_value=[ + McpToolCard(name=name, server_name="fixture", input_params={"type": "object", "properties": {}}) + for name in names])) + monkeypatch.setattr(mcp, "_ConfiguredNativeClient", Mock(return_value=client)) + dispatch = Mock() + monkeypatch.setattr(mcp, "_ManagedNativeClient", dispatch) + owner = mcp.JiuwenMCPCollection(manager=Mock(), cancellation_scope=RunCancellationScope(), + server_parameters=["http://fixture/mcp"], tool_timeout_seconds=1, close_timeout_seconds=1, + authorized_tools=[ToolConfig(name=saved, class_name=saved, source="mcp")], observe=Mock()) + owner._close_requested.set() + with pytest.raises(ValueError, match="missing or ambiguous"): + await owner._run_async_session() + dispatch.assert_not_called() + client.disconnect.assert_awaited_once() diff --git a/test/sdk/core/agents/test_jiuwen_model_service.py b/test/sdk/core/agents/test_jiuwen_model_service.py index 99138ac927..65e35c67cd 100644 --- a/test/sdk/core/agents/test_jiuwen_model_service.py +++ b/test/sdk/core/agents/test_jiuwen_model_service.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Isolated mapping, completion, stream and lifecycle contracts for Jiuwen's model bridge.""" import uuid @@ -462,3 +464,27 @@ async def test_persistent_bad_code_stops_after_one_correction(saved_profile): with pytest.raises(ModelServiceError, match="JIUWEN_NESTED_EXECUTION_BLOCK"): await model.invoke(MESSAGES) assert service.ainvoke.await_count == 2 and model.argument_corrections == 1 + + +@pytest.mark.parametrize("name,args,allowed", [ + ("nexent_execute_python", {"code": "print(1)"}, True), + ("nexent_execute_python", {"code": 1}, False), + ("nexent_execute_python", {"code": "print(1)", "shell": True}, False), + ("nexent_execute_block", {"code": "print(1)"}, False), + ("unregistered", {"code": "print(1)"}, False), +]) +async def test_native_file_call_requires_registered_tool_and_complete_schema(saved_profile, name, args, allowed): + import json + tools = [{"name": "nexent_execute_python", "parameters": { + "type": "object", "properties": {"code": {"type": "string"}}, + "required": ["code"], "additionalProperties": False}}] + result = AssistantMessage(content_parts=[], finish_reason="tool_calls", tool_calls=[ToolCall( + provider_call_id="file-call", index=0, name=name, raw_arguments=json.dumps(args), parsed_arguments=args)]) + model = JiuwenServiceModel(SimpleNamespace(ainvoke=AsyncMock(return_value=result)), + saved_profile, RunCancellationScope()) + if allowed: + response = await model.invoke(MESSAGES, tools=tools) + assert response.tool_calls[0].name == name + else: + with pytest.raises(ModelServiceError): + await model.invoke(MESSAGES, tools=tools) diff --git a/test/sdk/core/agents/test_jiuwen_native_files.py b/test/sdk/core/agents/test_jiuwen_native_files.py new file mode 100644 index 0000000000..405438db4e --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_native_files.py @@ -0,0 +1,98 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Native file creation shares sandbox execution without exposing text internals.""" + +import json +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.agent_model import ( + AgentConfig, + AgentRunInfo, + ModelConfig, + ToolConfig, +) +from nexent.core.agents.core_adapters.base import UnsupportedAgentCoreConfig +from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter +from nexent.core.agents.core_adapters.jiuwen_messages import resource_view +from nexent.core.agents.core_adapters.jiuwen_protocol import ( + EXECUTION_TOOL, + NATIVE_EXECUTION_TOOL, +) +from nexent.core.utils.observer import MessageObserver +from test_jiuwen_model_service import saved_profile as _saved_profile + +saved_profile = _saved_profile + + +@pytest.mark.parametrize("text_mode", [False, True]) +def test_executor_registration_reuses_sandbox_and_keeps_self_out_of_bindings(mocker, text_mode): + executor = Mock() + executor_result = SimpleNamespace(output="created.docx", logs="written", is_final_answer=False) + execute = mocker.patch("nexent.core.agents.sandbox._execute_with_tool_context", return_value=executor_result) + build = mocker.patch("nexent.core.agents.sandbox.build_python_executor") + adapter = JiuwenAdapter(SimpleNamespace(agent_config=SimpleNamespace(name="files"))) + adapter.text_protocol = text_mode + adapter.resources = SimpleNamespace(agent=SimpleNamespace(python_executor=executor)) + adapter.native_agent = SimpleNamespace(ability_manager=Mock()) + adapter.resource_bridge = SimpleNamespace(bindings=Mock(return_value={"upload_to_s3": Mock()})) + handler = mocker.patch.object(adapter, "_business_handler", side_effect=lambda name, fn, **kwargs: fn) + adapter._bind_text_executor() + build.assert_not_called() + card, _ = adapter.native_agent.ability_manager.add_ability.call_args.args + assert card.name == (EXECUTION_TOOL if text_mode else NATIVE_EXECUTION_TOOL) + assert card.input_params["additionalProperties"] is False + assert handler.call_args.kwargs["lane"] == "sandbox" + result = json.loads(handler.call_args.args[1]("print('created.docx')")) + execute.assert_called_once_with(executor, "print('created.docx')") + executor.send_tools.assert_called_once_with(adapter.resource_bridge.bindings()) + assert result == {"output": "created.docx", "logs": "written", "is_final_answer": False} + assert NATIVE_EXECUTION_TOOL not in adapter.visible_tool_names + + +def test_native_executor_is_model_visible_but_text_internal_entry_is_never_advertised(): + tools = [{"name": name, "parameters": {"type": "object"}} + for name in (EXECUTION_TOOL, NATIVE_EXECUTION_TOOL, "read_skill_md")] + view = resource_view("Create a document", tools) + assert [tool.name for tool in view.tool_definitions] == [NATIVE_EXECUTION_TOOL, "read_skill_md"] + + +def test_native_guidance_uses_authorized_skill_api_not_guessed_s3_paths(mocker): + mocker.patch("nexent.core.agents.run_agent._get_authorized_context_items", return_value=[]) + mocker.patch("nexent.core.agents.run_agent._get_authorized_history", return_value=[]) + info = SimpleNamespace(agent_config=SimpleNamespace(name="files", instructions="Write files", description="")) + adapter = JiuwenAdapter(info) + prompt = adapter._compile_context() + assert "read_skill_md" in prompt and "run_skill_script" in prompt + assert "never invent skill names, script paths or S3 paths" in prompt + assert "nexent_execute_python" in prompt and "never for skills" in prompt + + +@pytest.mark.parametrize("reserved", [EXECUTION_TOOL, NATIVE_EXECUTION_TOOL]) +def test_saved_tool_cannot_replace_runtime_owned_execution_entry(saved_profile, reserved): + info = AgentRunInfo(query="Create a file", observer=MessageObserver(lang="en"), stop_event=Event(), + tenant_id="tenant", agent_config=AgentConfig(name="files", description="Fixture", model_name="model", + tools=[ToolConfig(name=reserved, class_name="Fake", source="builtin")]), + model_config_list=[ModelConfig(cite_name="model", model_name=saved_profile.identity.request_model_id, + url=saved_profile.connection.base_url, resolved_profile=saved_profile)]) + with pytest.raises(UnsupportedAgentCoreConfig, match="Reserved"): + JiuwenAdapter(info).validate() + + +@pytest.mark.parametrize("class_name", ["ReadSkillMdTool", "ReadSkillConfigTool", "RunSkillScriptTool"]) +@pytest.mark.parametrize("authorized", [None, [], ["docx"]]) +def test_native_skill_rail_requires_explicit_snapshot_for_every_skill_tool(class_name, authorized, tmp_path): + adapter = JiuwenAdapter(SimpleNamespace(agent_config=SimpleNamespace(name="files"))) + params = {"local_skills_dir": str(tmp_path)} + if authorized is not None: + params["authorized_skill_names"] = authorized + adapter.tool_configs = [SimpleNamespace(class_name=class_name, params=params)] + if authorized is None: + with pytest.raises(UnsupportedAgentCoreConfig, match="explicit authorized"): + adapter._skill_rails() + else: + rails = adapter._skill_rails() + assert len(rails) == bool(authorized) + if rails: + assert rails[0]._names == frozenset({"docx"}) From 959ebe50f40e85aa6fa483b9d0ad70ebc1e78ec7 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 30 Oct 2026 18:12:26 +0800 Subject: [PATCH 11/26] refactor(sdk): migrate agent internals to canonical modules Separate contracts, resource orchestration, Core context bridges and sandbox modules. Migrate SDK, backend and test import sites and remove legacy alias modules. Preserve model implementations and backend business behavior. Validation: 92 isolated contract and import-boundary tests; syntax, copyright, unchanged backend business AST and frozen model blobs checked. --- backend/adapters/jiuwen_sdk_adapter.py | 2 +- backend/contracts/agent.py | 2 +- backend/contracts/clarification.py | 2 +- backend/infrastructure/runtime_output.py | 8 +- backend/management/services/agent/context.py | 2 +- backend/management/services/agent/creation.py | 2 +- .../services/agent/preparation/compiler.py | 8 +- .../services/agent/preparation/inputs.py | 2 +- .../services/agent/preparation/resources.py | 2 +- .../services/agent/preparation/run.py | 2 +- .../services/agent/preparation/tools.py | 2 +- .../services/agent/tool_identity.py | 2 +- .../management/services/agent/workbench.py | 2 +- .../services/automation/proposal.py | 4 +- .../services/configuration/user/memory.py | 2 +- .../management/services/evaluation/runs.py | 2 +- .../management/services/evaluation/scoring.py | 2 +- .../services/knowledge_base/scope.py | 2 +- .../services/model/outlet_migration.py | 2 +- .../management/services/model/run_bindings.py | 2 +- .../management/services/model/run_controls.py | 2 +- .../services/model/runtime_refresh.py | 2 +- backend/management/services/skill/creation.py | 2 +- backend/runtime/adapters/nexent/factory.py | 2 +- backend/runtime/adapters/nexent/runner.py | 2 +- backend/runtime/adapters/nexent/sandbox.py | 2 +- backend/runtime/api/application.py | 2 +- backend/runtime/executions.py | 2 +- backend/services/nl2agent_service.py | 2 +- backend/services/nl2skill_service.py | 2 +- .../tool_collection/mcp/nl2agent_mcp_tools.py | 2 +- sdk/benchmark/README.md | 2 +- sdk/benchmark/acon_eval/README.md | 2 +- sdk/benchmark/acon_eval/run_acon_qa.py | 3 +- sdk/benchmark/acon_eval/tools.py | 5 +- sdk/benchmark/agent_runner.py | 11 +- sdk/benchmark/eventqa_eval/run_eventqa.py | 3 +- .../longmemeval_eval/run_longmemeval.py | 3 +- .../longmemeval_eval/run_with_debugger.py | 3 +- .../manual_cases/summary_inspector.py | 3 +- sdk/benchmark/tools/ctx_debugger/README.md | 2 +- .../ctx_debugger/example_with_benchmark.py | 3 +- .../tools/ctx_debugger/interactive.py | 5 +- sdk/nexent/core/__init__.py | 3 +- .../core/agent_runtime_support/__init__.py | 5 - sdk/nexent/core/agents/__init__.py | 52 +- .../core/agents/agent_context/__init__.py | 56 - sdk/nexent/core/agents/context/__init__.py | 7 +- .../core/agents/context/compatibility.py | 30 + sdk/nexent/core/agents/context/evidence.py | 4 +- sdk/nexent/core/agents/context/llm_summary.py | 21 +- .../context/long_term_memory_selector.py | 6 +- sdk/nexent/core/agents/context/manager.py | 137 +- sdk/nexent/core/agents/context/models.py | 77 +- sdk/nexent/core/agents/context/projector.py | 12 +- sdk/nexent/core/agents/context/runtime.py | 197 -- .../agents/{ => context}/summary_cache.py | 2 + sdk/nexent/core/agents/context_input.py | 28 - sdk/nexent/core/agents/contracts/__init__.py | 3 + .../base.py => contracts/adapter.py} | 6 + .../agents/{ => contracts}/clarification.py | 15 +- .../{agent_model.py => contracts/config.py} | 155 +- sdk/nexent/core/agents/contracts/context.py | 215 ++ .../contracts}/output.py | 2 + .../contracts/resources.py} | 2 + sdk/nexent/core/agents/contracts/run.py | 155 ++ .../core/agents/core_adapters/__init__.py | 7 - sdk/nexent/core/agents/cores/__init__.py | 3 + .../core/agents/cores/jiuwen/__init__.py | 3 + .../jiuwen.py => cores/jiuwen/adapter.py} | 79 +- .../jiuwen/context.py} | 19 +- .../{core_adapters => cores/jiuwen}/events.py | 8 +- .../{core_adapters => cores/jiuwen}/mcp.py | 10 +- .../jiuwen/messages.py} | 8 +- .../jiuwen_model.py => cores/jiuwen/model.py} | 16 +- .../jiuwen/protocol.py} | 0 .../jiuwen/resources.py} | 4 +- .../{core_adapters => cores/jiuwen}/skills.py | 2 + .../core/agents/cores/smolagents/__init__.py | 3 + .../smolagents/adapter.py} | 6 +- .../core/agents/cores/smolagents/agent.py | 1004 ++++++++ .../core/agents/cores/smolagents/context.py | 378 +++ .../cores/smolagents/context_contracts.py} | 102 +- .../{ => cores/smolagents}/core_agent.py | 107 +- .../{ => cores/smolagents}/output_protocol.py | 11 +- .../{ => cores/smolagents}/plan_repo.py | 3 + .../core/agents/cores/smolagents/runner.py | 117 + .../{ => cores/smolagents}/tool_fastpath.py | 4 +- .../{ => cores/smolagents}/verification.py | 10 +- .../agents/{core_adapters => }/factory.py | 8 +- sdk/nexent/core/agents/nexent_agent.py | 2122 ----------------- .../core/agents/orchestration/__init__.py | 3 + .../children.py | 22 +- sdk/nexent/core/agents/orchestration/entry.py | 299 +++ .../core/agents/orchestration/execution.py | 101 + sdk/nexent/core/agents/output/__init__.py | 3 + .../output}/answer.py | 0 .../core/agents/prompt/clarification.py | 15 + sdk/nexent/core/agents/resources/__init__.py | 3 + .../{a2a_agent_proxy.py => resources/a2a.py} | 10 +- .../core/agents/resources/child_owner.py | 146 ++ .../resources/compatibility/__init__.py | 3 + .../compatibility/smolagents_tools.py | 19 + .../{managed_mcp.py => resources/mcp.py} | 118 +- .../core/agents/resources/monitoring.py | 113 + sdk/nexent/core/agents/resources/provider.py | 118 + .../subagents.py} | 6 +- .../agents/{ => resources}/tool_parameters.py | 2 + .../{ => resources}/tool_user_context.py | 2 + sdk/nexent/core/agents/resources/tools.py | 372 +++ sdk/nexent/core/agents/resources/workspace.py | 630 +++++ sdk/nexent/core/agents/run_agent.py | 590 ----- sdk/nexent/core/agents/sandbox/__init__.py | 3 + .../core/agents/sandbox/compatibility.py | 100 + sdk/nexent/core/agents/sandbox/config.py | 129 + .../{sandbox.py => sandbox/execution.py} | 271 +-- .../agents/{sandbox_tls.py => sandbox/tls.py} | 4 +- .../tls_bootstrap.py} | 2 + sdk/nexent/core/agents/sandbox_workspace.py | 96 - sdk/nexent/core/agents/summary_config.py | 16 - sdk/nexent/core/context_runtime/__init__.py | 20 - .../core/context_runtime/managed/__init__.py | 6 - .../core/context_runtime/managed/runtime.py | 16 - .../core/model_service/resource_conversion.py | 3 +- sdk/nexent/core/tools/parallel_executor.py | 3 +- sdk/nexent/core/tools/plan_tools.py | 3 +- sdk/nexent/core/tools/search_memory_tool.py | 3 +- sdk/nexent/core/tools/store_memory_tool.py | 3 +- .../cases/AGT-AUTO-01804EF17A1DF51B/test.py | 2 +- test-e2e/cases/CMSR-D1-003/test.py | 7 +- test-e2e/cases/CMSR-D1-004/test.py | 8 +- test-e2e/cases/FAF-D1-001/test.py | 6 +- .../JIUWEN-RECOVERY-D5-001/test_recovery.py | 3 +- .../JIUWEN-RELEASE-D3-001/deployed_smoke.py | 14 +- .../cases/REL-AUTO-1FAFCA5A681580F5/test.py | 2 +- test-e2e/cases/SAI-D1-001/test.py | 3 +- test-e2e/cases/SAI-D1-002/test.py | 3 +- test-e2e/cases/SAI-D1-005/test.py | 5 +- .../test_integration.py | 4 +- test-e2e/cases/TFP-D1-001/test.py | 9 +- test-e2e/cases/TFP-D1-004/test.py | 10 +- test-e2e/cases/TFP-D1-005/test.py | 11 +- test-e2e/cases/TFP-D1-006/test.py | 23 +- test-e2e/cases/TFP-D5-001/test.py | 3 +- .../cases/UT-BE-AUTO-4781AB28D8956C3F/test.py | 3 +- .../cases/UT-BE-AUTO-F2215F8782D68127/test.py | 3 +- test-e2e/cases/UT-SDK-016/test.py | 5 +- test-e2e/cases/UT-SDK-017/test.py | 3 +- test-e2e/cases/UT-SDK-018/test.py | 5 +- test-e2e/cases/UT-SDK-019/test.py | 3 +- .../UT-SDK-AUTO-35C9D72B4533593E/case.yaml | 2 +- .../UT-SDK-AUTO-74430BEE8438F486/test.py | 3 +- .../UT-SDK-AUTO-85A0FBE8116B9F9A/case.yaml | 2 +- .../UT-SDK-AUTO-85A0FBE8116B9F9A/test.py | 4 +- .../UT-SDK-AUTO-AA4383E2E3B57AD8/test.py | 3 +- .../UT-SDK-AUTO-E6DDFB4661E0D8B4/test.py | 3 +- .../UT-SDK-AUTO-ED416510CCE29002/test.py | 3 +- .../SDK-AGENT-CANONICAL-DEPLOY-20261029.yaml | 49 + .../SDK-AGENT-CANONICAL-IMPORTS-20261029.yaml | 94 + .../SDK-AGENT-DIRECTORY-R1-20261027.yaml | 55 + .../SDK-AGENT-DIRECTORY-R2-20261027.yaml | 59 + .../SDK-AGENT-DIRECTORY-R3-20261028.yaml | 71 + .../SDK-AGENT-DIRECTORY-R4-20261028.yaml | 53 + .../infra/automation/jiuwen_bridge_runtime.py | 15 +- .../jiuwen_collaboration_runtime.py | 37 +- .../automation/jiuwen_context_runtime.py | 24 +- .../automation/jiuwen_resource_runtime.py | 24 +- .../infra/automation/model_controls/api.py | 2 +- .../infra/automation/shared/sai_component.py | 7 +- .../infra/automation/shared/sai_docker.py | 16 +- .../automation/shared/sai_kernel_probe.py | 16 +- .../infra/automation/shared/tool_fastpath.py | 5 +- .../automation/shared/tool_fastpath_aidp.py | 9 +- .../shared/tool_fastpath_observation.py | 9 +- test/automation/d1/test_agent_core_factory.py | 15 +- .../d3/test_agent_core_factory_native.py | 12 +- .../d5/test_agent_core_factory_reliability.py | 10 +- .../agents/test_child_model_binding.py | 2 +- .../agents/test_context_budget_v2_pipeline.py | 4 +- test/backend/agents/test_create_agent_info.py | 29 +- .../agents/test_jiuwen_current_task.py | 2 +- .../agents/test_nl2agent_output_policy.py | 2 +- test/backend/agents/test_sandbox_config.py | 2 +- .../test_workbench_generation_overlay.py | 2 +- test/backend/app/test_agent_app.py | 4 +- .../services/test_agent_evaluation_service.py | 10 +- test/backend/services/test_agent_service.py | 19 +- .../test_conversation_management_service.py | 12 +- .../services/test_evaluation_pure_logic.py | 10 +- .../services/test_evaluation_set_service.py | 6 +- .../services/test_knowledge_scope_service.py | 4 +- ...test_model_runtime_provisioning_service.py | 4 +- .../services/test_nl2agent_name_validation.py | 2 +- .../services/test_nl2skill_continuation.py | 2 +- .../services/test_run_model_controls.py | 2 +- .../services/test_sampling_model_controls.py | 2 +- test/backend/services/test_skill_service.py | 10 +- .../test_tool_configuration_service.py | 4 +- .../services/test_workbench_service.py | 6 +- .../utils/test_time_context_localization.py | 2 +- .../observability/test_agent_display_name.py | 7 +- .../agents/observability/test_bridge_spans.py | 3 +- .../observability/test_managed_call_spans.py | 5 +- .../observability/test_run_span_context.py | 10 +- .../observability/test_sandbox_warmup.py | 22 +- .../observability/test_subagent_spans.py | 5 +- .../agents/observability/test_trace_name.py | 2 +- .../agents/test_a2a_active_cancellation.py | 9 +- test/sdk/core/agents/test_a2a_agent_proxy.py | 38 +- .../agents/test_agent_invocation_config.py | 18 +- test/sdk/core/agents/test_agent_model.py | 67 +- .../core/agents/test_agent_prompt_composer.py | 4 +- .../agents/test_canonical_agent_imports.py | 167 ++ .../core/agents/test_clarification_form.py | 3 +- .../agents/test_clarification_protocol.py | 11 +- .../core/agents/test_clarification_runtime.py | 13 +- .../agents/test_context_canonical_imports.py | 17 + .../test_context_compatibility_imports.py | 64 - .../test_context_directory_boundaries.py | 339 +++ .../agents/test_context_helper_contracts.py | 8 +- .../agents/test_context_import_isolation.py | 12 +- test/sdk/core/agents/test_context_input.py | 6 +- test/sdk/core/agents/test_core_agent.py | 41 +- .../core/agents/test_core_agent_planning.py | 57 +- .../core/agents/test_core_child_dispatch.py | 14 +- .../agents/test_direct_prompt_resources.py | 2 +- .../core/agents/test_fixed_system_prompts.py | 5 +- .../core/agents/test_guardrail_checkpoints.py | 25 +- test/sdk/core/agents/test_guardrail_engine.py | 14 +- .../agents/test_history_context_runtime.py | 3 +- .../agents/test_jiuwen_answer_boundary.py | 8 +- .../agents/test_jiuwen_capacity_output.py | 12 +- test/sdk/core/agents/test_jiuwen_context.py | 6 +- test/sdk/core/agents/test_jiuwen_history.py | 17 +- test/sdk/core/agents/test_jiuwen_mcp_names.py | 4 +- .../core/agents/test_jiuwen_mcp_transport.py | 3 +- .../core/agents/test_jiuwen_model_service.py | 19 +- .../core/agents/test_jiuwen_native_files.py | 27 +- test/sdk/core/agents/test_jiuwen_output.py | 17 +- test/sdk/core/agents/test_jiuwen_protocol.py | 6 +- test/sdk/core/agents/test_jiuwen_resources.py | 12 +- ...test_lowercase_output_protocol_isolated.py | 14 +- test/sdk/core/agents/test_managed_mcp.py | 3 +- test/sdk/core/agents/test_nexent_agent.py | 218 +- .../test_nexent_agent_bind_workspace.py | 12 +- ...st_nexent_agent_context_runtime_factory.py | 26 +- .../test_nexent_agent_sandbox_construction.py | 14 +- .../agents/test_nexent_agent_tool_fastpath.py | 20 +- .../test_nexent_agent_tool_fastpath_policy.py | 11 +- test/sdk/core/agents/test_output_protocol.py | 11 +- test/sdk/core/agents/test_plan_repo.py | 7 +- .../test_resource_orchestration_boundaries.py | 175 ++ test/sdk/core/agents/test_run_agent.py | 87 +- .../agents/test_sandbox_channel_readiness.py | 3 +- .../test_sandbox_directory_boundaries.py | 73 + .../core/agents/test_sandbox_lease_cleanup.py | 3 +- test/sdk/core/agents/test_sandbox_proxy.py | 7 +- .../agents/test_sandbox_system_network.py | 3 +- test/sdk/core/agents/test_sandbox_tls.py | 9 +- .../sdk/core/agents/test_sandbox_tls_files.py | 3 +- .../sdk/core/agents/test_sandbox_workspace.py | 5 +- .../agents/test_sandbox_workspace_config.py | 3 +- .../agents/test_sdk_langfuse_integration.py | 3 +- .../test_subagent_invocation_wrapper.py | 3 +- .../test_subagent_resource_ownership.py | 5 +- test/sdk/core/agents/test_subagent_wrapper.py | 3 +- .../agents/test_tool_fastpath_executor.py | 5 +- .../core/agents/test_tool_fastpath_grammar.py | 5 +- .../agents/test_tool_fastpath_registry.py | 3 +- test/sdk/core/agents/test_tool_parameters.py | 5 +- .../sdk/core/agents/test_tool_user_context.py | 5 +- .../core/agents/test_workbench_identity.py | 5 +- .../sdk/core/context_runtime/test_runtimes.py | 15 +- .../model_service/test_resource_conversion.py | 6 +- .../sdk/core/models/test_sampling_defaults.py | 2 +- .../tools/test_file_tools_bind_boundary.py | 3 +- test/sdk/core/tools/test_plan_tools.py | 6 +- .../sdk/core/tools/test_search_memory_tool.py | 3 +- .../tools/test_workbench_skill_readers.py | 3 +- test/sdk/monitor/test_monitoring.py | 2 +- .../test_run_agent_cancellation_controls.py | 5 +- 281 files changed, 6515 insertions(+), 4878 deletions(-) delete mode 100644 sdk/nexent/core/agent_runtime_support/__init__.py delete mode 100644 sdk/nexent/core/agents/agent_context/__init__.py create mode 100644 sdk/nexent/core/agents/context/compatibility.py delete mode 100644 sdk/nexent/core/agents/context/runtime.py rename sdk/nexent/core/agents/{ => context}/summary_cache.py (86%) delete mode 100644 sdk/nexent/core/agents/context_input.py create mode 100644 sdk/nexent/core/agents/contracts/__init__.py rename sdk/nexent/core/agents/{core_adapters/base.py => contracts/adapter.py} (83%) rename sdk/nexent/core/agents/{ => contracts}/clarification.py (85%) rename sdk/nexent/core/agents/{agent_model.py => contracts/config.py} (76%) create mode 100644 sdk/nexent/core/agents/contracts/context.py rename sdk/nexent/core/{agent_runtime_support => agents/contracts}/output.py (96%) rename sdk/nexent/core/{agent_runtime_support/contracts.py => agents/contracts/resources.py} (97%) create mode 100644 sdk/nexent/core/agents/contracts/run.py delete mode 100644 sdk/nexent/core/agents/core_adapters/__init__.py create mode 100644 sdk/nexent/core/agents/cores/__init__.py create mode 100644 sdk/nexent/core/agents/cores/jiuwen/__init__.py rename sdk/nexent/core/agents/{core_adapters/jiuwen.py => cores/jiuwen/adapter.py} (91%) rename sdk/nexent/core/agents/{core_adapters/jiuwen_context.py => cores/jiuwen/context.py} (92%) rename sdk/nexent/core/agents/{core_adapters => cores/jiuwen}/events.py (96%) rename sdk/nexent/core/agents/{core_adapters => cores/jiuwen}/mcp.py (95%) rename sdk/nexent/core/agents/{core_adapters/jiuwen_messages.py => cores/jiuwen/messages.py} (91%) rename sdk/nexent/core/agents/{core_adapters/jiuwen_model.py => cores/jiuwen/model.py} (95%) rename sdk/nexent/core/agents/{core_adapters/jiuwen_protocol.py => cores/jiuwen/protocol.py} (100%) rename sdk/nexent/core/agents/{core_adapters/jiuwen_resources.py => cores/jiuwen/resources.py} (95%) rename sdk/nexent/core/agents/{core_adapters => cores/jiuwen}/skills.py (96%) create mode 100644 sdk/nexent/core/agents/cores/smolagents/__init__.py rename sdk/nexent/core/agents/{core_adapters/smolagents.py => cores/smolagents/adapter.py} (75%) create mode 100644 sdk/nexent/core/agents/cores/smolagents/agent.py create mode 100644 sdk/nexent/core/agents/cores/smolagents/context.py rename sdk/nexent/core/{context_runtime/contracts.py => agents/cores/smolagents/context_contracts.py} (68%) rename sdk/nexent/core/agents/{ => cores/smolagents}/core_agent.py (97%) rename sdk/nexent/core/agents/{ => cores/smolagents}/output_protocol.py (98%) rename sdk/nexent/core/agents/{ => cores/smolagents}/plan_repo.py (97%) create mode 100644 sdk/nexent/core/agents/cores/smolagents/runner.py rename sdk/nexent/core/agents/{ => cores/smolagents}/tool_fastpath.py (98%) rename sdk/nexent/core/agents/{ => cores/smolagents}/verification.py (99%) rename sdk/nexent/core/agents/{core_adapters => }/factory.py (82%) delete mode 100644 sdk/nexent/core/agents/nexent_agent.py create mode 100644 sdk/nexent/core/agents/orchestration/__init__.py rename sdk/nexent/core/agents/{core_adapters => orchestration}/children.py (75%) create mode 100644 sdk/nexent/core/agents/orchestration/entry.py create mode 100644 sdk/nexent/core/agents/orchestration/execution.py create mode 100644 sdk/nexent/core/agents/output/__init__.py rename sdk/nexent/core/{agent_runtime_support => agents/output}/answer.py (100%) create mode 100644 sdk/nexent/core/agents/prompt/clarification.py create mode 100644 sdk/nexent/core/agents/resources/__init__.py rename sdk/nexent/core/agents/{a2a_agent_proxy.py => resources/a2a.py} (99%) create mode 100644 sdk/nexent/core/agents/resources/child_owner.py create mode 100644 sdk/nexent/core/agents/resources/compatibility/__init__.py create mode 100644 sdk/nexent/core/agents/resources/compatibility/smolagents_tools.py rename sdk/nexent/core/agents/{managed_mcp.py => resources/mcp.py} (75%) create mode 100644 sdk/nexent/core/agents/resources/monitoring.py create mode 100644 sdk/nexent/core/agents/resources/provider.py rename sdk/nexent/core/agents/{subagent_wrapper.py => resources/subagents.py} (98%) rename sdk/nexent/core/agents/{ => resources}/tool_parameters.py (94%) rename sdk/nexent/core/agents/{ => resources}/tool_user_context.py (98%) create mode 100644 sdk/nexent/core/agents/resources/tools.py create mode 100644 sdk/nexent/core/agents/resources/workspace.py delete mode 100644 sdk/nexent/core/agents/run_agent.py create mode 100644 sdk/nexent/core/agents/sandbox/__init__.py create mode 100644 sdk/nexent/core/agents/sandbox/compatibility.py create mode 100644 sdk/nexent/core/agents/sandbox/config.py rename sdk/nexent/core/agents/{sandbox.py => sandbox/execution.py} (94%) rename sdk/nexent/core/agents/{sandbox_tls.py => sandbox/tls.py} (95%) rename sdk/nexent/core/agents/{sandbox_tls_bootstrap.py => sandbox/tls_bootstrap.py} (98%) delete mode 100644 sdk/nexent/core/agents/sandbox_workspace.py delete mode 100644 sdk/nexent/core/agents/summary_config.py delete mode 100644 sdk/nexent/core/context_runtime/__init__.py delete mode 100644 sdk/nexent/core/context_runtime/managed/__init__.py delete mode 100644 sdk/nexent/core/context_runtime/managed/runtime.py create mode 100644 test-e2e/changes/refactors/SDK-AGENT-CANONICAL-DEPLOY-20261029.yaml create mode 100644 test-e2e/changes/refactors/SDK-AGENT-CANONICAL-IMPORTS-20261029.yaml create mode 100644 test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R1-20261027.yaml create mode 100644 test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R2-20261027.yaml create mode 100644 test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R3-20261028.yaml create mode 100644 test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R4-20261028.yaml create mode 100644 test/sdk/core/agents/test_canonical_agent_imports.py create mode 100644 test/sdk/core/agents/test_context_canonical_imports.py delete mode 100644 test/sdk/core/agents/test_context_compatibility_imports.py create mode 100644 test/sdk/core/agents/test_context_directory_boundaries.py create mode 100644 test/sdk/core/agents/test_resource_orchestration_boundaries.py create mode 100644 test/sdk/core/agents/test_sandbox_directory_boundaries.py diff --git a/backend/adapters/jiuwen_sdk_adapter.py b/backend/adapters/jiuwen_sdk_adapter.py index 66fcbb6ec7..e8dede1e38 100644 --- a/backend/adapters/jiuwen_sdk_adapter.py +++ b/backend/adapters/jiuwen_sdk_adapter.py @@ -265,7 +265,7 @@ def run_in_thread(): finally: new_loop.close() - from nexent.core.agents.run_agent import _get_default_agent_thread_manager + from nexent.core.agents.orchestration.entry import _get_default_agent_thread_manager from nexent.core.concurrency import ManagedTaskSpec, get_current_thread_manager manager = get_current_thread_manager() or _get_default_agent_thread_manager() diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index a36885962e..f980856239 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -13,7 +13,7 @@ from contracts.skill import SkillResolution, SkillZipEntry from contracts.tool import ToolParamsRequest from infrastructure.agent_prompt import normalize_imported_agent_bundle -from nexent.core.agents.agent_model import AgentVerificationConfig, ToolConfig +from nexent.core.agents.contracts.config import AgentVerificationConfig, ToolConfig from pydantic import ( BaseModel, ConfigDict, diff --git a/backend/contracts/clarification.py b/backend/contracts/clarification.py index 172b8e2c4a..a37395977f 100644 --- a/backend/contracts/clarification.py +++ b/backend/contracts/clarification.py @@ -11,7 +11,7 @@ def normalize_form(value: dict[str, Any]) -> dict[str, Any]: """Apply the runtime form contract without accepting execution fields.""" - from nexent.core.agents.clarification import ClarificationForm + from nexent.core.agents.contracts.clarification import ClarificationForm raw = dict(value) if type(raw.pop("schema_version", None)) is not int or value["schema_version"] != 1: diff --git a/backend/infrastructure/runtime_output.py b/backend/infrastructure/runtime_output.py index ca7a214294..92367f4857 100644 --- a/backend/infrastructure/runtime_output.py +++ b/backend/infrastructure/runtime_output.py @@ -3,12 +3,8 @@ import json -from nexent.core.agent_runtime_support.answer import ( - AnswerBoundaryError, - PublicTextFilter, - normalize_public_answer, -) -from nexent.core.agent_runtime_support.output import RunResult +from nexent.core.agents.output.answer import AnswerBoundaryError, PublicTextFilter, normalize_public_answer +from nexent.core.agents.contracts.output import RunResult def is_runtime_event(event): diff --git a/backend/management/services/agent/context.py b/backend/management/services/agent/context.py index 23b9d76437..8c3e23d0d4 100644 --- a/backend/management/services/agent/context.py +++ b/backend/management/services/agent/context.py @@ -9,7 +9,7 @@ from contracts.constants.constants import MESSAGE_ROLE from contracts.agent import AgentRunContext from nexent.core.agents.context import ContextItemInput -from nexent.core.agents.context_input import ContextInput +from nexent.core.agents.contracts.context import ContextInput from nexent.core.agents.prompt.user_context import ( CURRENT_TIME_MARKER, has_current_time_marker, diff --git a/backend/management/services/agent/creation.py b/backend/management/services/agent/creation.py index 04240ad910..f54637f82a 100644 --- a/backend/management/services/agent/creation.py +++ b/backend/management/services/agent/creation.py @@ -6,7 +6,7 @@ import json from contracts.constants.constants import LANGUAGE -from nexent.core.agents.agent_model import AgentConfig, ToolConfig +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig from nexent.core.agents.context import ContextItemInput, ContextItemType from nexent.core.agents.prompt.meta import compose_nl2agent_system from nexent.core.tools.parallel_executor import ParallelExecutorTool diff --git a/backend/management/services/agent/preparation/compiler.py b/backend/management/services/agent/preparation/compiler.py index 7d045a8823..0bd82b91a7 100644 --- a/backend/management/services/agent/preparation/compiler.py +++ b/backend/management/services/agent/preparation/compiler.py @@ -49,17 +49,13 @@ _resolve_context_budget, _resolve_input_budget, ) -from nexent.core.agents.agent_model import ( - AgentConfig, - AgentVerificationConfig, - ToolConfig, -) +from nexent.core.agents.contracts.config import AgentConfig, AgentVerificationConfig, ToolConfig from nexent.core.agents.context import ( ContextManagerConfig, PolicyLayers, resolve_policy, ) -from nexent.core.agents.nexent_agent import get_local_python_authorized_imports +from nexent.core.agents.cores.smolagents.agent import get_local_python_authorized_imports from nexent.core.agents.prompt import AgentPromptComposer from nexent.core.concurrency import run_blocking from nexent.memory import models as memory_models diff --git a/backend/management/services/agent/preparation/inputs.py b/backend/management/services/agent/preparation/inputs.py index d2456c44d1..c5ebecf51b 100644 --- a/backend/management/services/agent/preparation/inputs.py +++ b/backend/management/services/agent/preparation/inputs.py @@ -11,7 +11,7 @@ from contracts.errors.exceptions import WorkbenchError from management.services.agent.prompt import load_agent_prompt_bundle from management.services.file.service import validate_urls_access -from nexent.core.agents.agent_model import AgentHistory +from nexent.core.agents.contracts.run import AgentHistory from nexent.core.agents.prompt import AgentPromptComposer from nexent.core.agents.prompt.user_context import render_user_context from runtime.workspace import create_run_workspace diff --git a/backend/management/services/agent/preparation/resources.py b/backend/management/services/agent/preparation/resources.py index 9f9a899f90..2476750872 100644 --- a/backend/management/services/agent/preparation/resources.py +++ b/backend/management/services/agent/preparation/resources.py @@ -7,7 +7,7 @@ from contracts.constants.constants import PROTOCOL_JSONRPC from management.services.knowledge_base.service import ElasticSearchService -from nexent.core.agents.agent_model import ExternalA2AAgentConfig, ToolConfig +from nexent.core.agents.contracts.config import ExternalA2AAgentConfig, ToolConfig logger = logging.getLogger("create_agent_info") logger.setLevel(logging.INFO) diff --git a/backend/management/services/agent/preparation/run.py b/backend/management/services/agent/preparation/run.py index cde35e4040..c8e85da6f3 100644 --- a/backend/management/services/agent/preparation/run.py +++ b/backend/management/services/agent/preparation/run.py @@ -43,7 +43,7 @@ from management.services.mcp.service import get_remote_mcp_server_list from management.services.model.run_bindings import create_model_config_list from management.services.model.run_budget import _OPERATOR_OVERRIDE_FIELDS -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig from runtime.adapters.nexent.factory import build_run_info logger = logging.getLogger("create_agent_info") diff --git a/backend/management/services/agent/preparation/tools.py b/backend/management/services/agent/preparation/tools.py index f20ca8f3e6..6fb7a75e06 100644 --- a/backend/management/services/agent/preparation/tools.py +++ b/backend/management/services/agent/preparation/tools.py @@ -33,7 +33,7 @@ ) from management.services.model.adapter import get_llm_adapter, get_vlm_adapter from management.services.model.resolver import get_rerank_model -from nexent.core.agents.agent_model import AgentConfig, ToolConfig +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig from nexent.core.agents.prompt import get_builtin_tool_descriptions from nexent.core.concurrency import run_blocking from nexent.core.tools.parallel_executor import ParallelExecutorTool diff --git a/backend/management/services/agent/tool_identity.py b/backend/management/services/agent/tool_identity.py index 0e6676c1b6..e73b9bad19 100644 --- a/backend/management/services/agent/tool_identity.py +++ b/backend/management/services/agent/tool_identity.py @@ -6,7 +6,7 @@ from typing import Any from contracts.constants.constants import TENANT_NAME -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig logger = logging.getLogger(__name__) diff --git a/backend/management/services/agent/workbench.py b/backend/management/services/agent/workbench.py index a84f9b8ac9..ac3fa8236b 100644 --- a/backend/management/services/agent/workbench.py +++ b/backend/management/services/agent/workbench.py @@ -11,7 +11,7 @@ from math import isfinite from pathlib import Path from typing import Any, Dict, Mapping, Optional, Tuple -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig from contracts.errors.exceptions import ValidationError, WorkbenchConfigVersionConflict, WorkbenchError from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES, PERMISSION_PRIVATE diff --git a/backend/management/services/automation/proposal.py b/backend/management/services/automation/proposal.py index baad01be0b..305e504e13 100644 --- a/backend/management/services/automation/proposal.py +++ b/backend/management/services/automation/proposal.py @@ -20,7 +20,7 @@ from typing import Any, Callable, Dict, Optional from management.db.automation import repository as agent_automation_db -from nexent.core.agents.agent_model import ToolConfig +from nexent.core.agents.contracts.config import ToolConfig from nexent.core.concurrency import ManagedTaskSpec, get_current_thread_manager from nexent.core.tools.create_scheduled_task_tool import ( CreateScheduledTaskProposalTool, @@ -45,7 +45,7 @@ def _run_coroutine(coro): return asyncio.run(coro) manager = get_current_thread_manager() if manager is None: - from nexent.core.agents.run_agent import _get_default_agent_thread_manager + from nexent.core.agents.orchestration.entry import _get_default_agent_thread_manager manager = _get_default_agent_thread_manager() return manager.run_sync( diff --git a/backend/management/services/configuration/user/memory.py b/backend/management/services/configuration/user/memory.py index c386c39c00..4ac69989ba 100644 --- a/backend/management/services/configuration/user/memory.py +++ b/backend/management/services/configuration/user/memory.py @@ -5,7 +5,7 @@ from contracts.constants.constants import MEMORY_SWITCH_KEY, DREAMING_SWITCH_KEY, MEMORY_AGENT_SHARE_KEY, DISABLE_AGENT_ID_KEY, DISABLE_USERAGENT_ID_KEY, EXTERNAL_PROVIDER_TOP_K_KEY, DEFAULT_MEMORY_SWITCH_KEY, DEFAULT_DREAMING_SWITCH_KEY, DEFAULT_MEMORY_AGENT_SHARE_KEY, DEFAULT_EXTERNAL_PROVIDER_TOP_K from contracts.memory import MemoryAgentShareMode from management.db.memory.config import get_all_configs_by_user_id, get_memory_config_info, insert_config, delete_config_by_config_id, update_config_by_id -from nexent.core.agents.agent_model import MemoryContext, MemoryUserConfig +from nexent.core.agents.contracts.config import MemoryContext, MemoryUserConfig logger = logging.getLogger("memory_config_service") diff --git a/backend/management/services/evaluation/runs.py b/backend/management/services/evaluation/runs.py index b4331021f1..327f5298da 100644 --- a/backend/management/services/evaluation/runs.py +++ b/backend/management/services/evaluation/runs.py @@ -58,7 +58,7 @@ render_evaluation_section, ) from runtime.adapters.nexent.runner import agent_run -from nexent.core.agents.sandbox import _scan_shell_calls +from nexent.core.agents.sandbox.execution import _scan_shell_calls from nexent.core.concurrency import ManagedTaskSpec diff --git a/backend/management/services/evaluation/scoring.py b/backend/management/services/evaluation/scoring.py index 10eb02ea41..220d8919ef 100644 --- a/backend/management/services/evaluation/scoring.py +++ b/backend/management/services/evaluation/scoring.py @@ -23,7 +23,7 @@ compose_evaluator_run_prompt, render_evaluation_section, ) -from nexent.core.agents.sandbox import _scan_shell_calls +from nexent.core.agents.sandbox.execution import _scan_shell_calls from nexent.core.concurrency import ManagedTaskSpec diff --git a/backend/management/services/knowledge_base/scope.py b/backend/management/services/knowledge_base/scope.py index 07ef4fcaca..c09fee5719 100644 --- a/backend/management/services/knowledge_base/scope.py +++ b/backend/management/services/knowledge_base/scope.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Optional -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig from management.services.agent.resource_resolver import _resolve_runtime_tool_records from contracts.errors.exceptions import ValidationError diff --git a/backend/management/services/model/outlet_migration.py b/backend/management/services/model/outlet_migration.py index 6fa4233ba5..bc77d55900 100644 --- a/backend/management/services/model/outlet_migration.py +++ b/backend/management/services/model/outlet_migration.py @@ -6,7 +6,7 @@ from contracts.errors.exceptions import ModelOutletValidationError from contracts.model_profile import ModelProfilePreviewRequest -from nexent.core.agents.agent_model import ModelConfig +from nexent.core.agents.contracts.config import ModelConfig from nexent.core.model_service.compatibility.legacy_config import ( legacy_agent_request_parameters, legacy_thinking_enabled, diff --git a/backend/management/services/model/run_bindings.py b/backend/management/services/model/run_bindings.py index a22e38e079..7cb72befb8 100644 --- a/backend/management/services/model/run_bindings.py +++ b/backend/management/services/model/run_bindings.py @@ -11,7 +11,7 @@ from management.services.configuration.tenant.settings import tenant_config_manager from management.services.model.runtime_snapshot import restore_runtime_model_snapshot from management.services.model.names import add_repo_to_name, get_model_name_from_config -from nexent.core.agents.agent_model import ModelConfig +from nexent.core.agents.contracts.config import ModelConfig from nexent.core.models.prompt_cache import resolve_prompt_cache_profile logger = logging.getLogger("create_agent_info") diff --git a/backend/management/services/model/run_controls.py b/backend/management/services/model/run_controls.py index 7a282af886..809a786771 100644 --- a/backend/management/services/model/run_controls.py +++ b/backend/management/services/model/run_controls.py @@ -6,7 +6,7 @@ import math from typing import Any -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig from contracts.errors.exceptions import RunModelControlsError from management.db.model.repository import get_model_by_model_id diff --git a/backend/management/services/model/runtime_refresh.py b/backend/management/services/model/runtime_refresh.py index bf1658115e..b506370f68 100644 --- a/backend/management/services/model/runtime_refresh.py +++ b/backend/management/services/model/runtime_refresh.py @@ -8,7 +8,7 @@ def refresh_saved_outlet(record, profile, revision): """Preserve saved declarations and capabilities on legacy management edits.""" - from nexent.core.agents.agent_model import ModelConfig + from nexent.core.agents.contracts.config import ModelConfig from nexent.core.model_service.compatibility.legacy_config import ( legacy_extra_body, legacy_thinking_enabled, diff --git a/backend/management/services/skill/creation.py b/backend/management/services/skill/creation.py index a0bb7b25c4..3ca23b467f 100644 --- a/backend/management/services/skill/creation.py +++ b/backend/management/services/skill/creation.py @@ -2,7 +2,7 @@ """Build the ephemeral NL2Skill agent configuration.""" -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig from nexent.core.agents.context import ContextItemInput, ContextItemType NL2SKILL_NAME = "__skill_creator__" diff --git a/backend/runtime/adapters/nexent/factory.py b/backend/runtime/adapters/nexent/factory.py index 44e7ec5fb7..ad70828d4a 100644 --- a/backend/runtime/adapters/nexent/factory.py +++ b/backend/runtime/adapters/nexent/factory.py @@ -4,7 +4,7 @@ import threading -from nexent.core.agents.agent_model import AgentRunInfo +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.utils.observer import MessageObserver diff --git a/backend/runtime/adapters/nexent/runner.py b/backend/runtime/adapters/nexent/runner.py index d9347f7a4a..997f71b498 100644 --- a/backend/runtime/adapters/nexent/runner.py +++ b/backend/runtime/adapters/nexent/runner.py @@ -6,6 +6,6 @@ pre-admitted execution and managed worker ownership remain unchanged. """ -from nexent.core.agents.run_agent import DeferredAgentRun, agent_run +from nexent.core.agents.orchestration.entry import DeferredAgentRun, agent_run __all__ = ["DeferredAgentRun", "agent_run"] diff --git a/backend/runtime/adapters/nexent/sandbox.py b/backend/runtime/adapters/nexent/sandbox.py index 672065be0a..b4461c1a60 100644 --- a/backend/runtime/adapters/nexent/sandbox.py +++ b/backend/runtime/adapters/nexent/sandbox.py @@ -4,7 +4,7 @@ from typing import Any -from nexent.core.agents.sandbox import SandboxConfig +from nexent.core.agents.sandbox.config import SandboxConfig def resolve_sandbox_config( diff --git a/backend/runtime/api/application.py b/backend/runtime/api/application.py index 69ac1431d4..37ffdacbfc 100644 --- a/backend/runtime/api/application.py +++ b/backend/runtime/api/application.py @@ -76,7 +76,7 @@ async def runtime_lifespan(_app): "Agent stream tasks exceeded shutdown grace pending=%s", pending_stream_tasks, ) - from nexent.core.agents.sandbox import SandboxPoolManager + from nexent.core.agents.sandbox.execution import SandboxPoolManager sandbox_pool = SandboxPoolManager._instance if sandbox_pool is not None: diff --git a/backend/runtime/executions.py b/backend/runtime/executions.py index 8f1cf2afcc..d6a20fffdc 100644 --- a/backend/runtime/executions.py +++ b/backend/runtime/executions.py @@ -6,7 +6,7 @@ import uuid from typing import Dict, Union -from nexent.core.agents.agent_model import AgentRunInfo +from nexent.core.agents.contracts.run import AgentRunInfo logger = logging.getLogger("agent_run_manager") diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index 8c64e6d7c7..d6be31290b 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -14,7 +14,7 @@ from typing import Any from urllib.parse import quote, urljoin -from nexent.core.agents.agent_model import AgentHistory, AgentRunInfo +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo from nexent.core.agents.context import ( ContextItemInput, ContextItemType, diff --git a/backend/services/nl2skill_service.py b/backend/services/nl2skill_service.py index 43f990994c..d73dadc852 100644 --- a/backend/services/nl2skill_service.py +++ b/backend/services/nl2skill_service.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterator from typing import Any -from nexent.core.agents.agent_model import AgentHistory, AgentRunInfo +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo from nexent.core.agents.context import ContextManagerConfig from runtime.adapters.nexent.runner import agent_run from nexent.core.utils.observer import MessageObserver diff --git a/backend/tool_collection/mcp/nl2agent_mcp_tools.py b/backend/tool_collection/mcp/nl2agent_mcp_tools.py index faaaad792e..3e0e312c97 100644 --- a/backend/tool_collection/mcp/nl2agent_mcp_tools.py +++ b/backend/tool_collection/mcp/nl2agent_mcp_tools.py @@ -10,7 +10,7 @@ from typing import Annotated, Any, Literal from fastmcp.server.dependencies import get_http_request -from nexent.core.agents.agent_model import ToolConfig +from nexent.core.agents.contracts.config import ToolConfig from pydantic import ( BaseModel, ConfigDict, Field, StrictBool, StringConstraints, ValidationError, field_validator, model_validator, diff --git a/sdk/benchmark/README.md b/sdk/benchmark/README.md index 2254ecc0d3..5f6728cef6 100644 --- a/sdk/benchmark/README.md +++ b/sdk/benchmark/README.md @@ -303,7 +303,7 @@ for check in summary_checks: Run compression on pure text pairs without agent, using the same prompt and schema: ```python -from nexent.core.agents.agent_context import compress_history_offline +from nexent.core.agents.context import compress_history_offline result = compress_history_offline( pairs=[("What user said", "What assistant did"), ...], diff --git a/sdk/benchmark/acon_eval/README.md b/sdk/benchmark/acon_eval/README.md index 09310973fc..2905512e5e 100644 --- a/sdk/benchmark/acon_eval/README.md +++ b/sdk/benchmark/acon_eval/README.md @@ -113,7 +113,7 @@ class WikipediaSearchTool(Tool): ### 工具注册 -`register_acon_tools()` 将 `WikipediaSearchTool` 注入到 `nexent.core.tools` 和 `nexent.core.agents.nexent_agent` 模块的命名空间,使 `NexentAgent.create_local_tool()` 能通过 `globals()` 找到它。`get_acon_tool_configs(port)` 返回对应的 `ToolConfig` 列表。 +`register_acon_tools()` 将 `WikipediaSearchTool` 注入到 `nexent.core.tools` 和 `nexent.core.agents.cores.smolagents.agent` 模块的命名空间,使 `NexentAgent.create_local_tool()` 能通过 `globals()` 找到它。`get_acon_tool_configs(port)` 返回对应的 `ToolConfig` 列表。 ```python from tools import register_acon_tools, get_acon_tool_configs diff --git a/sdk/benchmark/acon_eval/run_acon_qa.py b/sdk/benchmark/acon_eval/run_acon_qa.py index 3b53166def..db716482ad 100644 --- a/sdk/benchmark/acon_eval/run_acon_qa.py +++ b/sdk/benchmark/acon_eval/run_acon_qa.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run ACON multi-objective QA benchmark with nexent agent. Loads ACON's nq_multi_8 data, builds a nexent CoreAgent with @@ -57,7 +58,7 @@ ContextManagerConfig, ) -from nexent.core.agents.agent_model import AgentHistory +from nexent.core.agents.contracts.run import AgentHistory from nexent.core.agents.context import ContextManager diff --git a/sdk/benchmark/acon_eval/tools.py b/sdk/benchmark/acon_eval/tools.py index 203c5a24d7..27ea32e1a6 100644 --- a/sdk/benchmark/acon_eval/tools.py +++ b/sdk/benchmark/acon_eval/tools.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ACON QA benchmark tools for nexent agent. Provides WikipediaSearchTool as a smolagents.Tool subclass, plus a helper @@ -7,7 +8,7 @@ import requests from smolagents.tools import Tool -from nexent.core.agents.agent_model import ToolConfig +from nexent.core.agents.contracts.config import ToolConfig class WikipediaSearchTool(Tool): @@ -76,7 +77,7 @@ def register_acon_tools(): already-executed `globals()`, we must inject into BOTH modules. """ import nexent.core.tools as _tools_mod - import nexent.core.agents.nexent_agent as _agent_mod + import nexent.core.agents.cores.smolagents.agent as _agent_mod for cls in (WikipediaSearchTool,): setattr(_tools_mod, cls.__name__, cls) setattr(_agent_mod, cls.__name__, cls) diff --git a/sdk/benchmark/agent_runner.py b/sdk/benchmark/agent_runner.py index a0c47e22af..3f75f2b400 100644 --- a/sdk/benchmark/agent_runner.py +++ b/sdk/benchmark/agent_runner.py @@ -34,19 +34,14 @@ build_context_inputs, ) -from nexent.core.agents.agent_model import ( # noqa: E402 - AgentConfig, - AgentHistory, - AgentRunInfo, - ModelConfig, - ToolConfig, -) +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo from nexent.core.agents.context import ( ContextItemInput, # noqa: E402 ContextManagerConfig, # noqa: E402 ) from nexent.core.agents.prompt import AgentPromptComposer # noqa: E402 -from nexent.core.agents.run_agent import agent_run # noqa: E402 +from nexent.core.agents.orchestration.entry import agent_run # noqa: E402 from nexent.core.utils.observer import MessageObserver # noqa: E402 diff --git a/sdk/benchmark/eventqa_eval/run_eventqa.py b/sdk/benchmark/eventqa_eval/run_eventqa.py index dd1e478d8a..b7f8f385ee 100644 --- a/sdk/benchmark/eventqa_eval/run_eventqa.py +++ b/sdk/benchmark/eventqa_eval/run_eventqa.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run the EventQA benchmark with the nexent agent. EventQA (MemoryAgentBench) gives a full novel plus 100 six-choice @@ -46,7 +47,7 @@ run_agent_with_tracking, ContextManagerConfig, ) -from nexent.core.agents.agent_model import AgentHistory +from nexent.core.agents.contracts.run import AgentHistory from nexent.core.agents.context import ContextManager from dataset import load_books, EventQABook diff --git a/sdk/benchmark/longmemeval_eval/run_longmemeval.py b/sdk/benchmark/longmemeval_eval/run_longmemeval.py index 89efa906fa..7b76b5a7b7 100644 --- a/sdk/benchmark/longmemeval_eval/run_longmemeval.py +++ b/sdk/benchmark/longmemeval_eval/run_longmemeval.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run the LongMemEval (S*) benchmark with the nexent agent. LongMemEval (S*) from MemoryAgentBench gives 5 long multi-session dialogues @@ -65,7 +66,7 @@ run_agent_with_tracking, ContextManagerConfig, ) -from nexent.core.agents.agent_model import AgentHistory +from nexent.core.agents.contracts.run import AgentHistory from nexent.core.agents.context import ContextManager from dataset import load_dialogues, LongMemEvalDialogue, LongMemEvalSession diff --git a/sdk/benchmark/longmemeval_eval/run_with_debugger.py b/sdk/benchmark/longmemeval_eval/run_with_debugger.py index 2fd10fbcc4..54195a67b0 100644 --- a/sdk/benchmark/longmemeval_eval/run_with_debugger.py +++ b/sdk/benchmark/longmemeval_eval/run_with_debugger.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run LongMemEval benchmark with ContextDebugger attached for Langfuse export. Usage: @@ -51,7 +52,7 @@ def _install_auto_attach(): """Wrap CoreAgent.__init__ to auto-attach debugger.""" - from nexent.core.agents.core_agent import CoreAgent # noqa: I001 + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent # noqa: I001 from ctx_debugger import attach_debugger from ctx_debugger.debugger import _wrap_compress_if_needed import logging diff --git a/sdk/benchmark/manual_cases/summary_inspector.py b/sdk/benchmark/manual_cases/summary_inspector.py index 481d0dd709..8e4dcf5560 100644 --- a/sdk/benchmark/manual_cases/summary_inspector.py +++ b/sdk/benchmark/manual_cases/summary_inspector.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ Standalone Summary Inspector — quick evaluation of compression prompt/schema quality. @@ -42,7 +43,7 @@ load_dotenv() from nexent.core.agents.context import ContextManagerConfig, compress_history_offline -from nexent.core.agents.agent_model import ModelConfig +from nexent.core.agents.contracts.config import ModelConfig from nexent.core.models.openai_llm import OpenAIModel from eval_utils import eval_text diff --git a/sdk/benchmark/tools/ctx_debugger/README.md b/sdk/benchmark/tools/ctx_debugger/README.md index 96e550ea25..5640cff937 100644 --- a/sdk/benchmark/tools/ctx_debugger/README.md +++ b/sdk/benchmark/tools/ctx_debugger/README.md @@ -160,7 +160,7 @@ Attach debugger to an agent or `ContextManager`. ```python from ctx_debugger import attach_debugger -from nexent.core.agents.agent_context import ContextManager +from nexent.core.agents.context import ContextManager cm = ContextManager(config=...) attach_debugger(cm, trace_path="/tmp/run.jsonl") # Only attach compression layer diff --git a/sdk/benchmark/tools/ctx_debugger/example_with_benchmark.py b/sdk/benchmark/tools/ctx_debugger/example_with_benchmark.py index 234ca59c19..9cdc82664c 100644 --- a/sdk/benchmark/tools/ctx_debugger/example_with_benchmark.py +++ b/sdk/benchmark/tools/ctx_debugger/example_with_benchmark.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run the generic benchmark with ContextDebugger attached, full layers, without touching benchmark or SDK source. @@ -43,7 +44,7 @@ def _install_auto_attach(): compress_* events and llm_call(tag=compression) events live in the same run. """ - from nexent.core.agents.core_agent import CoreAgent # noqa: I001 + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent # noqa: I001 from ctx_debugger import attach_debugger from ctx_debugger.debugger import _wrap_compress_if_needed import logging diff --git a/sdk/benchmark/tools/ctx_debugger/interactive.py b/sdk/benchmark/tools/ctx_debugger/interactive.py index 792905c0c2..100fa468c1 100644 --- a/sdk/benchmark/tools/ctx_debugger/interactive.py +++ b/sdk/benchmark/tools/ctx_debugger/interactive.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Interactive context debugger REPL. Type user messages one at a time. Each line runs one agent turn against an @@ -65,7 +66,7 @@ _OUT = sys.stdout from nexent.core.agents.context import ContextManager, ContextManagerConfig -from nexent.core.agents.agent_model import AgentHistory +from nexent.core.agents.contracts.run import AgentHistory from nexent.core.utils.token_estimation import estimate_tokens_text from ctx_debugger import ContextDebugger, attach_debugger, resolve_trace_path @@ -159,7 +160,7 @@ def __init__(self, token_threshold=3000, keep_recent_pairs=1, def _install_agent_patch(self): """Patch CoreAgent.__init__ so each turn's fresh agent wires its model/observer/tools/executor layers onto this session's debugger.""" - from nexent.core.agents.core_agent import CoreAgent + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent dbg = self.debugger if getattr(CoreAgent, "_ctxdbg_orig_init", None) is None: diff --git a/sdk/nexent/core/__init__.py b/sdk/nexent/core/__init__.py index 87b3f28696..82d956a1a5 100644 --- a/sdk/nexent/core/__init__.py +++ b/sdk/nexent/core/__init__.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from .utils.observer import MessageObserver, ProcessType __all__ = ["MessageObserver", "ProcessType"] # Lazy imports to avoid circular dependencies def get_core_agent(): - from .agents import CoreAgent + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent return CoreAgent def get_openai_model(): diff --git a/sdk/nexent/core/agent_runtime_support/__init__.py b/sdk/nexent/core/agent_runtime_support/__init__.py deleted file mode 100644 index abcf44ce5a..0000000000 --- a/sdk/nexent/core/agent_runtime_support/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Shared Agent runtime contracts and scoped support; no engine selection.""" - -from .output import RunResult, StepObservation - -__all__ = ["RunResult", "StepObservation"] diff --git a/sdk/nexent/core/agents/__init__.py b/sdk/nexent/core/agents/__init__.py index 3c5ca07c34..5fb615e96c 100644 --- a/sdk/nexent/core/agents/__init__.py +++ b/sdk/nexent/core/agents/__init__.py @@ -1,51 +1,3 @@ -"""Lazy public exports for agent modules. +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -Do not eagerly import CoreAgent or ContextManager here. Python executes package -``__init__`` before loading submodules such as ``nexent.core.agents.agent_model``; -eager imports would collapse the ContextManager-on/off isolation at import time. -""" -from __future__ import annotations - -from importlib import import_module -from typing import Any - - -_AGENT_MODEL_MODULE = ".agent_model" - - -_EXPORTS = { - "CoreAgent": (".core_agent", "CoreAgent"), - "PlanRepo": (".plan_repo", "PlanRepo"), - "ModelConfig": (_AGENT_MODEL_MODULE, "ModelConfig"), - "ToolConfig": (_AGENT_MODEL_MODULE, "ToolConfig"), - "AgentConfig": (_AGENT_MODEL_MODULE, "AgentConfig"), - "AgentRunInfo": (_AGENT_MODEL_MODULE, "AgentRunInfo"), - "AgentHistory": (_AGENT_MODEL_MODULE, "AgentHistory"), - "ContextItem": (".context", "ContextItem"), - "ContextItemInput": (".context", "ContextItemInput"), - "ContextItemType": (".context", "ContextItemType"), - "SandboxConfig": (".sandbox", "SandboxConfig"), - "SandboxLevel": (".sandbox", "SandboxLevel"), - "SandboxScope": (".sandbox", "SandboxScope"), - "ShellPolicy": (".sandbox", "ShellPolicy"), - "SandboxPoolManager": (".sandbox", "SandboxPoolManager"), - "build_python_executor": (".sandbox", "build_python_executor"), - "cleanup_executor": (".sandbox", "cleanup_executor"), - "release_python_executor": (".sandbox", "release_python_executor"), - "ContextManager": (".context", "ContextManager"), - "CompressionCallRecord": (".summary_cache", "CompressionCallRecord"), - "ContextManagerConfig": (".context", "ContextManagerConfig"), -} - - -def __getattr__(name: str) -> Any: - try: - module_name, attr_name = _EXPORTS[name] - except KeyError as exc: - raise AttributeError(name) from exc - value = getattr(import_module(module_name, __name__), attr_name) - globals()[name] = value - return value - - -__all__ = list(_EXPORTS) +"""Canonical Agent contracts, resources, orchestration and Core implementations.""" diff --git a/sdk/nexent/core/agents/agent_context/__init__.py b/sdk/nexent/core/agents/agent_context/__init__.py deleted file mode 100644 index b5ab2aa673..0000000000 --- a/sdk/nexent/core/agents/agent_context/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Deprecated compatibility path for :mod:`nexent.core.agents.context`.""" - -from __future__ import annotations - -import sys -import warnings -from importlib import import_module - - -COMPATIBILITY_REMOVAL_VERSION = "v2.4.0" -warnings.warn( - "nexent.core.agents.agent_context is deprecated; use nexent.core.agents.context; " - f"the compatibility path will be removed in {COMPATIBILITY_REMOVAL_VERSION}", - DeprecationWarning, - stacklevel=2, -) - -_CONTEXT_PACKAGE = f"{__package__.rsplit('.', 1)[0]}.context" -_SUBMODULES = ( - "budget", - "config", - "history_compression", - "llm_summary", - "manager", - "models", - "policy", - "run_context", - "selection", - "step_renderer", -) -for _name in _SUBMODULES: - sys.modules[f"{__name__}.{_name}"] = import_module(f"{_CONTEXT_PACKAGE}.{_name}") - -from ..context import ( # noqa: E402 - ContextManager, - ContextManagerConfig, - HistoryCompressor, - HistorySummaryCandidate, - ManagedRunContext, - _is_context_length_error, - format_summary_output, -) -from ..summary_cache import CompressionCallRecord # noqa: E402 - - -__all__ = [ - "CompressionCallRecord", - "COMPATIBILITY_REMOVAL_VERSION", - "ContextManager", - "ContextManagerConfig", - "HistoryCompressor", - "HistorySummaryCandidate", - "ManagedRunContext", - "_is_context_length_error", - "format_summary_output", -] diff --git a/sdk/nexent/core/agents/context/__init__.py b/sdk/nexent/core/agents/context/__init__.py index 880d5cf1de..b14c8f42cc 100644 --- a/sdk/nexent/core/agents/context/__init__.py +++ b/sdk/nexent/core/agents/context/__init__.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Unified agent context domain with lazy public exports.""" from __future__ import annotations @@ -8,8 +10,8 @@ _EXPORTS = { "ContextItem": (".models", "ContextItem"), - "ContextItemInput": (".models", "ContextItemInput"), - "ContextItemType": (".models", "ContextItemType"), + "ContextItemInput": ("nexent.core.agents.contracts.context", "ContextItemInput"), + "ContextItemType": ("nexent.core.agents.contracts.context", "ContextItemType"), "SystemContextItem": (".models", "SystemContextItem"), "ToolContextItem": (".models", "ToolContextItem"), "SkillContextItem": (".models", "SkillContextItem"), @@ -27,7 +29,6 @@ "ContextItemRenderingError": (".rendering", "ContextItemRenderingError"), "ContextManager": (".manager", "ContextManager"), "ContextManagerConfig": (".config", "ContextManagerConfig"), - "ManagedContextRuntime": (".runtime", "ManagedContextRuntime"), "ManagedRunContext": (".run_context", "ManagedRunContext"), "HistoryCompressor": (".history_compression", "HistoryCompressor"), "HistorySummaryCandidate": (".history_compression", "HistorySummaryCandidate"), diff --git a/sdk/nexent/core/agents/context/compatibility.py b/sdk/nexent/core/agents/context/compatibility.py new file mode 100644 index 0000000000..2cf3bb6e49 --- /dev/null +++ b/sdk/nexent/core/agents/context/compatibility.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit lazy support for the legacy ContextManager memory API.""" + + +def memory_adapter(manager): + from nexent.core.agents.cores.smolagents.context import SmolagentsMemoryAdapter + + return SmolagentsMemoryAdapter(manager) + + +def assemble_legacy_context(manager, *, model, memory, current_run_start_idx, run_context=None, **kwargs): + from dataclasses import replace + + from nexent.core.agents.cores.smolagents.context import adapt_context_model, context_messages, context_tool_views + from nexent.core.agents.cores.smolagents.context_contracts import native_context_messages + + tools = kwargs.get("tools") + kwargs["tools"] = context_tool_views(tools) + kwargs["request_system_messages"] = context_messages(kwargs.get("request_system_messages", ())) + kwargs["request_tail_messages"] = context_messages(kwargs.get("request_tail_messages", ())) + final = manager.assemble_context_items( + model=adapt_context_model(model), + current_items=memory_adapter(manager)._project_current_run(memory, current_run_start_idx), + run_context=run_context or manager.prepare_items_context(""), + **kwargs, + ) + return replace( + final, messages=native_context_messages(final.messages), tools=manager._canonical_tools(tools or ()) + ) diff --git a/sdk/nexent/core/agents/context/evidence.py b/sdk/nexent/core/agents/context/evidence.py index 969bbb6cc1..33aed6a859 100644 --- a/sdk/nexent/core/agents/context/evidence.py +++ b/sdk/nexent/core/agents/context/evidence.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Run-scoped aggregation for one evidence record per agent loop.""" from __future__ import annotations @@ -7,7 +9,7 @@ from dataclasses import asdict, replace from threading import Lock -from ...context_runtime.contracts import ContextEvidence +from nexent.core.agents.contracts.context import ContextEvidence logger = logging.getLogger("context_evidence") diff --git a/sdk/nexent/core/agents/context/llm_summary.py b/sdk/nexent/core/agents/context/llm_summary.py index 0fe4e51f68..b8a5562241 100644 --- a/sdk/nexent/core/agents/context/llm_summary.py +++ b/sdk/nexent/core/agents/context/llm_summary.py @@ -1,18 +1,21 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """LLM call orchestration for summary generation with retry and error handling.""" +import inspect import logging import re -import inspect +from collections.abc import Mapping from contextlib import contextmanager from dataclasses import dataclass, field from typing import List, Optional -from smolagents.models import ChatMessage, MessageRole +from nexent.core.agents.contracts.context import ContextMessage -from ..summary_cache import CompressionCallRecord +from ...utils.observer import MessageObserver from .budget import _is_context_length_error from .config import ContextManagerConfig -from ...utils.observer import MessageObserver +from .summary_cache import CompressionCallRecord logger = logging.getLogger("agent_context.llm_summary") @@ -168,12 +171,14 @@ def _do_generate_summary( f"Conversation content to summarize:\n{text}" ) messages = [ - ChatMessage(role=MessageRole.SYSTEM, + ContextMessage(role="system", content=[{"type": "text", "text": system_prompt}]), - ChatMessage(role=MessageRole.USER, + ContextMessage(role="user", content=[{"type": "text", "text": user_prompt}]), ] - parameters = inspect.signature(model.__call__).parameters + parameters = getattr(model, "context_call_parameters", None) + if not isinstance(parameters, Mapping): + parameters = inspect.signature(model.__call__).parameters accepts_kwargs = any( parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() @@ -229,7 +234,7 @@ def _record_llm_call_token(self, input_len, output_len, response, call_type) -> @staticmethod def _msg_char_count(messages) -> int: - """Count characters in a list of ChatMessage objects.""" + """Count characters in a list of context messages.""" total = 0 for msg in messages: content = getattr(msg, "content", "") diff --git a/sdk/nexent/core/agents/context/long_term_memory_selector.py b/sdk/nexent/core/agents/context/long_term_memory_selector.py index 54fdde00a8..4a223817d7 100644 --- a/sdk/nexent/core/agents/context/long_term_memory_selector.py +++ b/sdk/nexent/core/agents/context/long_term_memory_selector.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Validated, one-call selection of relevant Markdown long-term memory blocks.""" from __future__ import annotations @@ -9,7 +11,7 @@ from dataclasses import dataclass from typing import Any, Iterable -from smolagents.models import ChatMessage, MessageRole +from nexent.core.agents.contracts.context import ContextMessage @dataclass(frozen=True) @@ -117,7 +119,7 @@ def select_long_term_memory( "rules": ["Return JSON only", "Select IDs only", "Keep each ID at most once"], }, ensure_ascii=False) try: - response = model([ChatMessage(role=MessageRole.USER, content=[{"type": "text", "text": prompt}])], + response = model([ContextMessage(role="user", content=[{"type": "text", "text": prompt}])], stop_sequences=[]) output = response.content if isinstance(output, list): diff --git a/sdk/nexent/core/agents/context/manager.py b/sdk/nexent/core/agents/context/manager.py index 8da4ee76c0..eb56959b10 100644 --- a/sdk/nexent/core/agents/context/manager.py +++ b/sdk/nexent/core/agents/context/manager.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Managed context assembly for fine-grained items and adaptive compaction.""" from __future__ import annotations @@ -12,11 +14,8 @@ from enum import Enum from typing import Any, Dict, Optional, Sequence -from smolagents.memory import ActionStep, AgentMemory, TaskStep -from smolagents.models import ChatMessage, MessageRole +from nexent.core.agents.contracts.context import ContextEvidence, FinalContext, ModelMessage -from ...context_runtime.contracts import ContextEvidence, FinalContext -from ..summary_cache import CompressionCallRecord from .budget import extract_message_text, message_role from .config import ContextManagerConfig from .history_compression import HistoryCompressor, HistorySummaryCandidate @@ -27,6 +26,7 @@ from .run_context import ManagedRunContext from .selection import select_context_items from .step_renderer import StepRenderer +from .summary_cache import CompressionCallRecord logger = logging.getLogger("agent_context") @@ -78,7 +78,7 @@ def processing_mode(self) -> str: def prepare_run_context( self, - memory: AgentMemory, + memory, fallback_system_prompt: str, items: Optional[Sequence[Any]] = None, ) -> ManagedRunContext: @@ -119,25 +119,14 @@ def prepare_items_context( selection_decision=decision, ) - def assemble_final_context( - self, - *, - model: Any, - memory: AgentMemory, - current_run_start_idx: int, - tools: Sequence[Any] | None = None, - purpose: str = "step", - task: str | None = None, - final_answer_templates: Optional[Dict[str, Any]] = None, - run_context: ManagedRunContext | None = None, - force_compaction: bool = False, - request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), - request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), - ) -> FinalContext: - """Project legacy memory once, then use the Core-independent assembly path.""" - run_context = run_context or self.prepare_run_context(memory, "") - return self.assemble_context_items( - model=model, current_items=self._project_current_run(memory, current_run_start_idx), + def assemble_final_context(self, *, model, memory, current_run_start_idx, + tools=None, purpose="step", task=None, final_answer_templates=None, + run_context=None, force_compaction=False, + request_system_messages=(), request_tail_messages=()): + """Compatibility entry for the old native-memory SDK interface.""" + from .compatibility import assemble_legacy_context + return assemble_legacy_context( + self, model=model, memory=memory, current_run_start_idx=current_run_start_idx, tools=tools, purpose=purpose, task=task, final_answer_templates=final_answer_templates, run_context=run_context, force_compaction=force_compaction, request_system_messages=request_system_messages, request_tail_messages=request_tail_messages, @@ -154,8 +143,8 @@ def assemble_context_items( final_answer_templates: Optional[Dict[str, Any]] = None, run_context: ManagedRunContext | None = None, force_compaction: bool = False, - request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), - request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), + request_system_messages: Sequence[ModelMessage] = (), + request_tail_messages: Sequence[ModelMessage] = (), ) -> FinalContext: if purpose != "step" and (request_system_messages or request_tail_messages): raise ValueError("Request-only action messages require step purpose") @@ -518,73 +507,15 @@ def _select_long_term_memories(self, result, memory_items, *, model): return [replacement_by_id.get(item.id, item) for item in result] - def _neutral_action_content(self, step: ActionStep, action_index: int) -> dict[str, Any]: - """Preserve completed-action evidence without upstream protocol labels.""" - return { - "step_number": getattr(step, "step_number", action_index + 1), - "tool_calls": self._to_json_value(getattr(step, "tool_calls", None)), - "observations": self._to_json_value(getattr(step, "observations", None)), - "error": str(getattr(step, "error", "")) if getattr(step, "error", None) else None, - "result": self._to_json_value(getattr(step, "action_output", None)), - } + def _neutral_action_content(self, step, action_index): + """Compatibility entry for callers with native memory records.""" + from .compatibility import memory_adapter + return memory_adapter(self)._neutral_action_content(step, action_index) - def _project_current_run(self, memory: AgentMemory, start: int) -> list[ContextItem]: - projected: list[ContextItem] = [] - action_index = planning_index = 0 - for index, step in enumerate(memory.steps[start:]): - cached = self._current_item_cache.get(id(step)) - if cached is not None: - projected.append(cached) - if cached.type == ContextItemType.CURRENT_ACTION: - action_index += 1 - elif cached.type == ContextItemType.CURRENT_PLANNING: - planning_index += 1 - continue - if isinstance(step, TaskStep): - item = ContextItem.from_input( - ContextItemInput( - id=f"current_task:{index}", - type=ContextItemType.CURRENT_TASK, - content={"text": step.task or ""}, - metadata={"layout_order": index, "run_order": index}, - ) - ) - projected.append(item) - elif isinstance(step, ActionStep): - content = self._neutral_action_content(step, action_index) - # Render completed actions from structured fields instead of - # smolagents ActionStep.to_messages(). The upstream rendering - # injects protocol labels such as "Calling tools:" and - # "Observation:" into the next model request, which can prime - # reasoning models to emit a configured stop sequence. The - # neutral representation preserves the executable action, - # outcome, error, result, and ordering without changing how - # the live ReAct step is parsed or executed. - item = ContextItem.from_input( - ContextItemInput( - id=f"current_action:{action_index}", - type=ContextItemType.CURRENT_ACTION, - content=content, - metadata={"layout_order": action_index, "run_order": index}, - ) - ) - projected.append(item) - action_index += 1 - elif step.__class__.__name__ == "PlanningStep": - item = ContextItem.from_input( - ContextItemInput( - id=f"current_planning:{planning_index}", - type=ContextItemType.CURRENT_PLANNING, - content={"text": "\n".join(extract_message_text(m) for m in step.to_messages())}, - metadata={"layout_order": planning_index, "run_order": index}, - ) - ) - projected.append(item) - planning_index += 1 - else: - continue - self._current_item_cache[id(step)] = item - return projected + def _project_current_run(self, memory, start): + """Compatibility entry; production Core bridges project their own memory.""" + from .compatibility import memory_adapter + return memory_adapter(self)._project_current_run(memory, start) def _persist_candidate(self, candidate: HistorySummaryCandidate) -> str: sink = self.config.history_summary_sink @@ -659,25 +590,9 @@ def build_compressed_snapshot(self, model, memory, current_run_start_idx): } def render_memory_messages(self, memory): - messages = [] - if memory.system_prompt: - messages.extend(memory.system_prompt.to_messages()) - action_index = 0 - for step in memory.steps: - if isinstance(step, ActionStep): - item = ContextItem.from_input( - ContextItemInput( - id=f"summary_action:{action_index}", - type=ContextItemType.CURRENT_ACTION, - content=self._neutral_action_content(step, action_index), - metadata={"layout_order": action_index}, - ) - ) - messages.extend(self.build_context_messages([item])) - action_index += 1 - else: - messages.extend(step.to_messages()) - return messages + """Compatibility entry for display-only native memory rendering.""" + from .compatibility import memory_adapter + return memory_adapter(self).render_memory_messages(memory) def register_item(self, item): normalized = self._item_source([item])[0] diff --git a/sdk/nexent/core/agents/context/models.py b/sdk/nexent/core/agents/context/models.py index a77d3cb5c8..592249aedd 100644 --- a/sdk/nexent/core/agents/context/models.py +++ b/sdk/nexent/core/agents/context/models.py @@ -1,29 +1,18 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Immutable, fine-grained context items used by the managed runtime.""" from __future__ import annotations import json from copy import deepcopy -from enum import Enum, IntEnum +from enum import IntEnum from threading import Lock from typing import Any, ClassVar -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator - +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator -class ContextItemType(str, Enum): - SYSTEM = "system" - TOOL = "tool" - SKILL = "skill" - MEMORY = "memory" - KNOWLEDGE_BASE = "knowledge_base" - WORKER_AGENT = "worker_agent" - EXTERNAL_AGENT = "external_agent" - HISTORY_SUMMARY = "history_summary" - CONVERSATION_TURN = "conversation_turn" - CURRENT_TASK = "current_task" - CURRENT_PLANNING = "current_planning" - CURRENT_ACTION = "current_action" +from nexent.core.agents.contracts.context import ContextItemInput, ContextItemType class ContextSection(IntEnum): @@ -43,62 +32,6 @@ class ContextSection(IntEnum): CURRENT_ACTION = 110 -class ContextItemInput(BaseModel): - """Serializable backend-to-SDK DTO; it never carries database objects.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - id: str = Field(min_length=1) - type: ContextItemType - content: dict[str, Any] - source: tuple[str, ...] = () - priority: int = 10 - metadata: dict[str, Any] = Field(default_factory=dict) - - @field_validator("content", "metadata") - @classmethod - def _require_serializable(cls, value: Any) -> Any: - try: - json.dumps(value, ensure_ascii=False) - except (TypeError, ValueError) as exc: - raise ValueError("context input payload must be JSON serializable") from exc - return value - - @field_validator("metadata") - @classmethod - def _reject_source_objects(cls, value: dict[str, Any]) -> dict[str, Any]: - if "_source_component" in value: - raise ValueError("_source_component fallback is not permitted") - return value - - @model_validator(mode="after") - def _validate_payload(self) -> "ContextItemInput": - required_fields = { - ContextItemType.TOOL: ("name",), - ContextItemType.SKILL: ("name",), - ContextItemType.WORKER_AGENT: ("name",), - ContextItemType.EXTERNAL_AGENT: ("agent_id", "name"), - ContextItemType.HISTORY_SUMMARY: ("summary", "covered_through_message_id"), - ContextItemType.CONVERSATION_TURN: ( - "user_message", "assistant_final_answer", - "user_message_id", "assistant_message_id", - ), - }.get(self.type, ()) - missing = [name for name in required_fields if self.content.get(name) is None] - if missing: - raise ValueError(f"{self.type.value} content missing fields: {', '.join(missing)}") - if self.type == ContextItemType.MEMORY and not any( - self.content.get(name) for name in ("memory", "content", "text") - ): - raise ValueError("memory content requires memory, content or text") - if self.type in { - ContextItemType.SYSTEM, ContextItemType.KNOWLEDGE_BASE, - ContextItemType.CURRENT_TASK, ContextItemType.CURRENT_PLANNING, - } and "template" not in self.content and not isinstance(self.content.get("text"), str): - raise ValueError(f"{self.type.value} content requires text") - return self - - class ContextItem(BaseModel): """Run-local immutable view with one optional deterministic compact form.""" diff --git a/sdk/nexent/core/agents/context/projector.py b/sdk/nexent/core/agents/context/projector.py index e69f38c7d9..5c911dd5ef 100644 --- a/sdk/nexent/core/agents/context/projector.py +++ b/sdk/nexent/core/agents/context/projector.py @@ -1,18 +1,10 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ContextProjector: converts ContextComponent instances into fine-grained ContextItem candidates.""" import uuid from typing import Any, Dict, List -from ..agent_model import ( - ContextComponent, - ExternalAgentsComponent, - KnowledgeBaseComponent, - ManagedAgentsComponent, - MemoryComponent, - SkillsComponent, - SystemPromptComponent, - ToolsComponent, -) +from nexent.core.agents.contracts.config import ContextComponent, ExternalAgentsComponent, KnowledgeBaseComponent, ManagedAgentsComponent, MemoryComponent, SkillsComponent, SystemPromptComponent, ToolsComponent from .context_item import AuthorityTier, ContextItem, ContextItemType, RepresentationTier from .item_handler_registry import ItemHandlerRegistry diff --git a/sdk/nexent/core/agents/context/runtime.py b/sdk/nexent/core/agents/context/runtime.py deleted file mode 100644 index f74321e163..0000000000 --- a/sdk/nexent/core/agents/context/runtime.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Managed context path thin adapter. - -All context policy and final payload assembly belongs to ContextManager. This -runtime only adapts CoreAgent lifecycle calls to the ContextManager API. -""" -from __future__ import annotations - -from collections.abc import Sequence -from typing import Mapping - -from smolagents.memory import AgentMemory -from smolagents.models import ChatMessage, Model - -from ...context_runtime.contracts import ContextEvidence, FinalContext, ModelTool -from .evidence import ContextEvidenceCollector -from .manager import ContextManager -from .models import ContextItem, ContextItemInput -from .run_context import ManagedRunContext - - -ContextItemCandidate = ContextItemInput | ContextItem - - -class ManagedContextRuntime: - """Adapter for the ContextManager-owned managed path.""" - - def __init__( - self, - context_manager: ContextManager, - items: Sequence[ContextItemCandidate] | None = None, - ) -> None: - self._context_manager = context_manager - self.items = list(items or ()) - self._run_context: ManagedRunContext | None = None - self._evidence = ContextEvidenceCollector() - - @property - def context_manager(self) -> ContextManager: - """Return the manager permanently bound to this runtime.""" - return self._context_manager - - def replace_items(self, items: Sequence[ContextItemCandidate] | None) -> None: - """Replace this runtime's run-local fine-grained item snapshot.""" - self.items = list(items or ()) - self._run_context = None - - def prepare_run(self, *, memory: AgentMemory, fallback_system_prompt: str) -> None: - self._evidence.reset() - self._run_context = self.context_manager.prepare_run_context( - memory=memory, - fallback_system_prompt=fallback_system_prompt, - items=self.items, - ) - - def _ensure_run_context(self, memory: AgentMemory) -> ManagedRunContext: - if self._run_context is None: - self._run_context = self.context_manager.prepare_run_context( - memory=memory, - fallback_system_prompt="", - items=self.items, - ) - return self._run_context - - def prepare_step( - self, - *, - model: Model, - memory: AgentMemory, - current_run_start_idx: int, - tools: Sequence[ModelTool] | None = None, - request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), - request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), - ) -> FinalContext: - final_context = self.context_manager.assemble_final_context( - model=model, - memory=memory, - current_run_start_idx=current_run_start_idx, - tools=tools, - purpose="step", - run_context=self._ensure_run_context(memory), - request_system_messages=request_system_messages, - request_tail_messages=request_tail_messages, - ) - self._evidence.record_call(final_context.evidence) - return final_context - - def recover_step( - self, - *, - model: Model, - memory: AgentMemory, - current_run_start_idx: int, - tools: Sequence[ModelTool] | None = None, - request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), - request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), - ) -> FinalContext: - """Force a source-backed rebuild after explicit Provider overflow.""" - final_context = self.context_manager.assemble_final_context( - model=model, - memory=memory, - current_run_start_idx=current_run_start_idx, - tools=tools, - purpose="step", - run_context=self._ensure_run_context(memory), - force_compaction=True, - request_system_messages=request_system_messages, - request_tail_messages=request_tail_messages, - ) - self._evidence.record_call(final_context.evidence) - return final_context - - def prepare_final_answer( - self, - *, - model: Model, - memory: AgentMemory, - current_run_start_idx: int, - task: str, - final_answer_templates: Mapping[str, Mapping[str, str]], - tools: Sequence[ModelTool] | None = None, - ) -> FinalContext: - final_context = self.context_manager.assemble_final_context( - model=model, - memory=memory, - current_run_start_idx=current_run_start_idx, - tools=tools, - purpose="final_answer", - task=task, - final_answer_templates=final_answer_templates, - run_context=self._ensure_run_context(memory), - ) - self._evidence.record_call(final_context.evidence) - return final_context - - def recover_final_answer( - self, - *, - model: Model, - memory: AgentMemory, - current_run_start_idx: int, - task: str, - final_answer_templates: Mapping[str, Mapping[str, str]], - tools: Sequence[ModelTool] | None = None, - ) -> FinalContext: - """Force a final-answer rebuild after explicit Provider overflow.""" - final_context = self.context_manager.assemble_final_context( - model=model, - memory=memory, - current_run_start_idx=current_run_start_idx, - tools=tools, - purpose="final_answer", - task=task, - final_answer_templates=final_answer_templates, - run_context=self._ensure_run_context(memory), - force_compaction=True, - ) - self._evidence.record_call(final_context.evidence) - return final_context - - def render_summary_messages(self, *, memory: AgentMemory) -> list[ChatMessage | dict[str, object]]: - """Return display-only memory messages without compression side effects.""" - return self.context_manager.render_memory_messages(memory) - - def compression_stats(self) -> dict[str, object]: - return self.context_manager.get_step_compression_stats() - - def global_compression_stats(self) -> dict[str, object]: - return self.context_manager.get_all_compression_stats() - - def token_counts(self) -> dict[str, int | None]: - return self.context_manager.get_token_counts() - - def consume_history_summary_event(self) -> dict[str, object] | None: - return self.context_manager.consume_history_summary_event() - - def finalize_evidence(self, *, status: str) -> ContextEvidence: - return self._evidence.finalize(status=status) - - @property - def chars_per_token(self) -> float: - return self.context_manager.config.chars_per_token - - @property - def token_threshold(self) -> int | None: - return self.context_manager.config.token_threshold - - @property - def context_window_tokens(self) -> int | None: - return self.context_manager.config.context_window_tokens - - @property - def effective_input_limit_tokens(self) -> int | None: - return self.context_manager.effective_input_limit_tokens - - @property - def processing_mode(self) -> str | None: - return self.context_manager.processing_mode diff --git a/sdk/nexent/core/agents/summary_cache.py b/sdk/nexent/core/agents/context/summary_cache.py similarity index 86% rename from sdk/nexent/core/agents/summary_cache.py rename to sdk/nexent/core/agents/context/summary_cache.py index 6bdfa813dd..bd8f898e87 100644 --- a/sdk/nexent/core/agents/summary_cache.py +++ b/sdk/nexent/core/agents/context/summary_cache.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """LLM compression call record used by context evidence.""" from dataclasses import dataclass diff --git a/sdk/nexent/core/agents/context_input.py b/sdk/nexent/core/agents/context_input.py deleted file mode 100644 index 5fc20bd85f..0000000000 --- a/sdk/nexent/core/agents/context_input.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Run-scoped input contract for context data authorized by the application.""" - -from copy import deepcopy -from dataclasses import dataclass -from typing import Tuple - -from .context.models import ContextItemInput - - -@dataclass(frozen=True) -class ContextInput: - """Immutable context snapshot supplied by the application boundary. - - The SDK consumes this snapshot without loading business data or inferring - user and tenant permissions. Every item is fully materialized and - authorized by the application before crossing this boundary. - """ - - items: Tuple[ContextItemInput, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.items, tuple): - raise TypeError("ContextInput collections must be immutable tuples") - frozen_items = tuple( - ContextItemInput.model_validate(deepcopy(item.model_dump())) - for item in self.items - ) - object.__setattr__(self, "items", frozen_items) diff --git a/sdk/nexent/core/agents/contracts/__init__.py b/sdk/nexent/core/agents/contracts/__init__.py new file mode 100644 index 0000000000..5bc6d95535 --- /dev/null +++ b/sdk/nexent/core/agents/contracts/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Agent contracts namespace; implementations load on demand.""" diff --git a/sdk/nexent/core/agents/core_adapters/base.py b/sdk/nexent/core/agents/contracts/adapter.py similarity index 83% rename from sdk/nexent/core/agents/core_adapters/base.py rename to sdk/nexent/core/agents/contracts/adapter.py index f252261276..2651064a60 100644 --- a/sdk/nexent/core/agents/core_adapters/base.py +++ b/sdk/nexent/core/agents/contracts/adapter.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Core-independent execution and lifecycle contract.""" from abc import ABC, abstractmethod @@ -34,3 +36,7 @@ def run(self) -> Any: def close(self) -> None: """Release only resources owned by this adapter; must be idempotent.""" + + +class ModelOutputProtocolExhaustedError(Exception): + """Terminal failure after the runtime exhausts protocol repair attempts.""" diff --git a/sdk/nexent/core/agents/clarification.py b/sdk/nexent/core/agents/contracts/clarification.py similarity index 85% rename from sdk/nexent/core/agents/clarification.py rename to sdk/nexent/core/agents/contracts/clarification.py index 99eb2df30d..7ddc63e96e 100644 --- a/sdk/nexent/core/agents/clarification.py +++ b/sdk/nexent/core/agents/contracts/clarification.py @@ -1,17 +1,14 @@ -"""Declarative clarification forms shared by the runtime and application boundary.""" +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Declarative clarification DTOs and deterministic pure validation.""" from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator -from ..prompts import load_prompt - Identifier = Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")] -CLARIFICATION_SCHEMA_GUIDANCE = load_prompt("en", "agent/human_interaction")["questions_description"] -CLARIFICATION_POLICY = load_prompt("en", "agent/human_interaction")["clarification_policy"] - class ClarificationOption(BaseModel): model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True) @@ -68,12 +65,6 @@ def validate_questions(self): return self -def clarification_policy(tool_name: str, language: str = "en") -> str: - """Describe the selected terminal form name without registering a Python tool.""" - policy = load_prompt(language, "agent/human_interaction")["clarification_policy"] - return policy.replace("ask_user", tool_name) - - def render_question_text(form: ClarificationForm) -> str: """Render a deterministic, complete fallback for model history and text clients.""" lines = [] diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/contracts/config.py similarity index 76% rename from sdk/nexent/core/agents/agent_model.py rename to sdk/nexent/core/agents/contracts/config.py index a2e515d56c..904c54167a 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/contracts/config.py @@ -4,19 +4,17 @@ from copy import deepcopy from dataclasses import fields, is_dataclass -from threading import Event from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator -from ..models.capacity_budget import ContextBudgetSnapshot -from ..models.sampling import DEFAULT_TEMPERATURE, DEFAULT_TOP_P, resolve_sampling -from ..utils.observer import MessageObserver -from .context.models import ContextItemInput +from nexent.core.agents.contracts.context import ContextItemInput +from nexent.core.models.capacity_budget import ContextBudgetSnapshot +from nexent.core.models.sampling import DEFAULT_TEMPERATURE, DEFAULT_TOP_P, resolve_sampling if TYPE_CHECKING: - from .a2a_agent_proxy import A2AAgentInfo + from nexent.core.agents.resources.a2a import A2AAgentInfo def copy_invocation_config(config: "AgentConfig") -> "AgentConfig": """Copy configuration data while retaining injected runtime objects.""" @@ -415,11 +413,6 @@ class AgentConfig(BaseModel): ) -class AgentHistory(BaseModel): - role: str = Field(description="Role, can be user or assistant") - content: str = Field(description="Conversation content") - - class PlanStep(BaseModel): """Single step within an agent plan.""" @@ -440,144 +433,6 @@ class AgentPlan(BaseModel): current_step_index: int = Field(description="Index of the currently executing step", default=0) -class AgentRunInfo(BaseModel): - collaboration_timeout_seconds: float = Field(default=300.0, gt=0, - description="Total deadline for a nested collaboration including queue wait") - model_service: Optional[Any] = Field(default=None, exclude=True, repr=False) - model_deadline: Optional[float] = Field(default=None, exclude=True) - run_result: Optional[Any] = Field(default=None, exclude=True, description="Explicit public runtime result") - attempt_outcome: Optional[str] = Field(default=None, description="Typed worker outcome for the application host") - query: str = Field(description="User query") - model_config_list: List[ModelConfig] = Field(description="List of model configurations") - observer: MessageObserver = Field(description="Return data") - agent_config: AgentConfig = Field(description="Detailed Agent configuration") - mcp_host: Optional[List[Union[str, Dict[str, Any]]]] = Field( - description="MCP server address(es). Can be a string (URL) or dict with 'url', 'transport', " - "and optionally 'authorization', 'headers', or 'httpx_client_factory' keys. " - "Transport can be 'sse' or 'streamable-http'. If string, transport is auto-detected based on URL ending: " - "URLs ending with '/sse' use 'sse' transport, URLs ending with '/mcp' use 'streamable-http' transport. " - "Authorization can be provided as 'authorization' (e.g., 'Bearer token') or as 'headers' dict.", - default=None, - ) - mcp_request_timeout_seconds: Optional[float] = Field( - description=( - "Maximum seconds allowed to establish an MCP connection and complete " - "the initialization handshake. It does not limit tool execution." - ), - default=None, - ) - history: Optional[List[AgentHistory]] = Field(description="Historical conversation information", default=None) - stop_event: Event = Field(description="Stop event control") - cancellation_scope: Optional[Any] = Field( - description="Run-scoped cancellation and active resource registry", - default=None, - exclude=True, - ) - mcp_tool_timeout_seconds: float = Field( - description="Hard timeout for one MCP tool invocation", - default=60.0, - gt=0, - ) - mcp_close_timeout_seconds: float = Field( - description="Maximum wait for an MCP context to close", - default=5.0, - gt=0, - ) - thread_manager: Optional[Any] = Field( - description="Injected process-local thread manager", - default=None, - exclude=True, - ) - thread_execution_id: Optional[str] = Field( - description="Process-local managed execution identifier", - default=None, - exclude=True, - ) - thread_future: Optional[Any] = Field( - description="Process-local managed execution completion handle", - default=None, - exclude=True, - ) - conversation_id: Optional[int] = Field(description="Conversation id for run-scoped persistence", default=None) - user_id: Optional[str] = Field(description="User id for run-scoped persistence", default=None) - user_context: Optional[Dict[str, Any]] = Field( - description="Caller user context (tenant/user/groups) passed through to tools for " - "tool-side authorization. Hidden from the model; values come only from the " - "authenticated session, never from model output.", - default=None, - exclude=True, - ) - runtime_metadata: Dict[str, Any] = Field( - description="Immutable application-resolved runtime metadata snapshot", - default_factory=dict, - ) - runtime_metadata_version: Optional[int] = Field( - description="Conversation runtime metadata version used by this run", - default=None, - ) - context_input: Optional[Any] = Field( - description="Immutable run-scoped context snapshot supplied by the application boundary.", - default=None, - ) - capacity_snapshot: Optional[Dict[str, Any]] = Field( - description="Resolved model capacity snapshot fields for request monitoring", - default=None, - ) - context_budget_snapshot: Optional[ContextBudgetSnapshot] = Field( - description="Resolved canonical W2 context budget snapshot for request execution", - default=None, - ) - enable_planning: bool = Field(description="Whether to enable the planning phase before execution", default=False) - redis_client: Optional[Any] = Field( - description="Redis client for plan persistence. " - "If provided, plan_repo will use Redis as primary storage with local fallback.", - default=None, - ) - sandbox_config: Optional[Any] = Field( - description=( - "Resolved SandboxConfig for sandbox isolation. " - "Populated by the backend service layer from AgentConfig.sandbox_policy " - "and NEXENT_SANDBOX_* environment variables. " - "When None the SDK uses LocalPythonExecutor (backwards-compatible)." - ), - default=None, - ) - tool_fastpath_enabled: bool = Field( - description="Enable restricted official-tool actions before ordinary Docker initialization.", - default=True, - ) - tool_fastpath_allowed_tools: Optional[List[str]] = Field( - description="Deployment-approved official implementation IDs for local fast-path actions.", - default=None, - ) - minio_client: Optional[Any] = Field( - description=( - "MinIO client for syncing sandbox output files to object storage. " - "Required when sandbox_config.auto_sync_outputs is True." - ), - default=None, - ) - workspace_path: Optional[str] = Field( - description="Run-scoped local workspace used for uploaded inputs and generated outputs.", - default=None, - ) - workspace_run_id: Optional[str] = Field( - description="Opaque run identifier used to isolate workspace files and object keys.", - default=None, - ) - tenant_id: Optional[str] = Field( - description="Tenant id used for run-scoped file isolation.", - default=None, - ) - minio_files: Optional[List[Dict[str, Any]]] = Field( - description="Authorized files uploaded with the current run request.", - default=None, - ) - - class Config: - arbitrary_types_allowed = True - - class MemoryContext(BaseModel): user_config: MemoryUserConfig = Field(description="Memory user configuration") tenant_id: str = Field(description="Tenant id") @@ -660,7 +515,7 @@ def _build_skills_description(self) -> str: def to_a2a_agent_info(self) -> "A2AAgentInfo": """Convert to A2AAgentInfo for SDK usage.""" - from .a2a_agent_proxy import A2AAgentInfo + from nexent.core.agents.resources.a2a import A2AAgentInfo return A2AAgentInfo( agent_id=self.agent_id, diff --git a/sdk/nexent/core/agents/contracts/context.py b/sdk/nexent/core/agents/contracts/context.py new file mode 100644 index 0000000000..7af6cf9a12 --- /dev/null +++ b/sdk/nexent/core/agents/contracts/context.py @@ -0,0 +1,215 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Core-independent authorized context inputs, messages and evidence.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Protocol, Tuple + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator + + +class ContextItemType(str, Enum): + SYSTEM = "system" + TOOL = "tool" + SKILL = "skill" + MEMORY = "memory" + KNOWLEDGE_BASE = "knowledge_base" + WORKER_AGENT = "worker_agent" + EXTERNAL_AGENT = "external_agent" + HISTORY_SUMMARY = "history_summary" + CONVERSATION_TURN = "conversation_turn" + CURRENT_TASK = "current_task" + CURRENT_PLANNING = "current_planning" + CURRENT_ACTION = "current_action" + + +class ContextItemInput(BaseModel): + """Serializable backend-to-SDK DTO; it never carries database objects.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + id: str = Field(min_length=1) + type: ContextItemType + content: dict[str, Any] + source: tuple[str, ...] = () + priority: int = 10 + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("content", "metadata") + @classmethod + def _require_serializable(cls, value: Any) -> Any: + try: + json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise ValueError("context input payload must be JSON serializable") from exc + return value + + @field_validator("metadata") + @classmethod + def _reject_source_objects(cls, value: dict[str, Any]) -> dict[str, Any]: + if "_source_component" in value: + raise ValueError("_source_component fallback is not permitted") + return value + + @model_validator(mode="after") + def _validate_payload(self) -> "ContextItemInput": + required_fields = { + ContextItemType.TOOL: ("name",), + ContextItemType.SKILL: ("name",), + ContextItemType.WORKER_AGENT: ("name",), + ContextItemType.EXTERNAL_AGENT: ("agent_id", "name"), + ContextItemType.HISTORY_SUMMARY: ("summary", "covered_through_message_id"), + ContextItemType.CONVERSATION_TURN: ( + "user_message", + "assistant_final_answer", + "user_message_id", + "assistant_message_id", + ), + }.get(self.type, ()) + missing = [name for name in required_fields if self.content.get(name) is None] + if missing: + raise ValueError(f"{self.type.value} content missing fields: {', '.join(missing)}") + if self.type == ContextItemType.MEMORY and not any( + self.content.get(name) for name in ("memory", "content", "text") + ): + raise ValueError("memory content requires memory, content or text") + if ( + self.type + in { + ContextItemType.SYSTEM, + ContextItemType.KNOWLEDGE_BASE, + ContextItemType.CURRENT_TASK, + ContextItemType.CURRENT_PLANNING, + } + and "template" not in self.content + and not isinstance(self.content.get("text"), str) + ): + raise ValueError(f"{self.type.value} content requires text") + return self + + +@dataclass(frozen=True) +class ContextInput: + """Immutable context snapshot supplied by the application boundary. + + The SDK consumes this snapshot without loading business data or inferring + user and tenant permissions. Every item is fully materialized and + authorized by the application before crossing this boundary. + """ + + items: Tuple[ContextItemInput, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.items, tuple): + raise TypeError("ContextInput collections must be immutable tuples") + frozen_items = tuple(ContextItemInput.model_validate(deepcopy(item.model_dump())) for item in self.items) + object.__setattr__(self, "items", frozen_items) + + +@dataclass(frozen=True) +class ContextMessage: + """Neutral message for auxiliary CM model calls; no native message instance.""" + + role: str + content: str | list[dict[str, JsonValue]] + fingerprint_payload: dict[str, JsonValue] | None = None + + def model_dump(self): + """Retain the bridge's existing audit representation without native objects.""" + if self.fingerprint_payload is not None: + return deepcopy(self.fingerprint_payload) + return {"role": self.role, "content": deepcopy(self.content)} + + +@dataclass(frozen=True) +class ContextToolView: + """Detached tool audit data with the pre-existing token-estimation text.""" + + fingerprint_payload: dict[str, JsonValue] + budget_text: str + + def model_dump(self): + return deepcopy(self.fingerprint_payload) + + def __str__(self): + return self.budget_text + + +ModelMessage = ContextMessage | dict[str, object] +ModelTool = ContextToolView | dict[str, object] + + +class ContextSummaryResponse(Protocol): + """Summary response fields consumed by common context algorithms.""" + + content: str | list[dict[str, JsonValue]] + + +class ContextSummaryModel(Protocol): + """Core supplies native conversion and the configured model invocation.""" + + def __call__( + self, messages: list[ModelMessage], *, stop_sequences: list[str], **parameters: object + ) -> ContextSummaryResponse: + """Invoke the already selected model without selecting a Core or outlet.""" + + +@dataclass(frozen=True) +class ContextEvidence: + purpose: str = "step" + selected_item_ids: tuple[str, ...] = () + selected_item_types: tuple[str, ...] = () + stable_message_count: int = 0 + dynamic_message_count: int = 0 + compression_records: tuple[object, ...] = () + stable_prefix_fingerprint: str | None = None + prefix_change_reasons: tuple[str, ...] = () + policy_fingerprint: str | None = None + processing_mode: str = "passthrough" + effective_input_limit_tokens: int = 0 + compaction_trigger_threshold_tokens: int = 0 + compaction_target_tokens: int = 0 + compaction_attempts: int = 0 + compressible_history_tokens: int = 0 + non_history_tokens: int = 0 + compaction_stop_reason: str = "not_triggered" + raw_token_estimate: int = 0 + final_token_estimate: int = 0 + loaded_summary_unit_id: int | None = None + loaded_summary_coverage: int | None = None + new_history_turn_count: int = 0 + history_compression_triggered: bool = False + new_summary_coverage: int | None = None + summary_persist_status: str = "not_attempted" + item_representations: tuple[tuple[str, str], ...] = () + current_action_compact_count: int = 0 + representation_cache_hits: int = 0 + representation_cache_misses: int = 0 + compaction_attempts_exhausted: bool = False + exceeds_effective_input_limit: bool = False + model_call_count: int = 0 + loop_status: str | None = None + messages_fingerprint: str | None = None + tools_fingerprint: str | None = None + system_messages_fingerprint: str | None = None + history_messages_fingerprint: str | None = None + final_answer_prompt_fingerprint: str | None = None + message_roles: tuple[str, ...] = () + history_message_roles: tuple[str, ...] = () + compression_attempted: bool = False + fallback_compaction_used: bool = False + + +@dataclass(frozen=True) +class FinalContext: + """The only context payload permitted to enter a model call.""" + + messages: list[ModelMessage] + tools: list[dict[str, object]] = field(default_factory=list) + evidence: ContextEvidence = field(default_factory=ContextEvidence) + memory_messages: list[ModelMessage] | None = None diff --git a/sdk/nexent/core/agent_runtime_support/output.py b/sdk/nexent/core/agents/contracts/output.py similarity index 96% rename from sdk/nexent/core/agent_runtime_support/output.py rename to sdk/nexent/core/agents/contracts/output.py index a9df7c7051..9c7b7a259e 100644 --- a/sdk/nexent/core/agent_runtime_support/output.py +++ b/sdk/nexent/core/agents/contracts/output.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Public observations and explicit Agent results, independent of engine memory.""" from typing import Literal, Self diff --git a/sdk/nexent/core/agent_runtime_support/contracts.py b/sdk/nexent/core/agents/contracts/resources.py similarity index 97% rename from sdk/nexent/core/agent_runtime_support/contracts.py rename to sdk/nexent/core/agents/contracts/resources.py index 2f049bf42a..a21b22d835 100644 --- a/sdk/nexent/core/agent_runtime_support/contracts.py +++ b/sdk/nexent/core/agents/contracts/resources.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Selected model-visible resources shared by Agent runtime implementations.""" from typing import Annotated, Self diff --git a/sdk/nexent/core/agents/contracts/run.py b/sdk/nexent/core/agents/contracts/run.py new file mode 100644 index 0000000000..6e634dd310 --- /dev/null +++ b/sdk/nexent/core/agents/contracts/run.py @@ -0,0 +1,155 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +from __future__ import annotations + +from threading import Event +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field + +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig +from nexent.core.models.capacity_budget import ContextBudgetSnapshot +from nexent.core.utils.observer import MessageObserver + + +class AgentHistory(BaseModel): + role: str = Field(description="Role, can be user or assistant") + content: str = Field(description="Conversation content") + + +class AgentRunInfo(BaseModel): + collaboration_timeout_seconds: float = Field(default=300.0, gt=0, + description="Total deadline for a nested collaboration including queue wait") + model_service: Optional[Any] = Field(default=None, exclude=True, repr=False) + model_deadline: Optional[float] = Field(default=None, exclude=True) + run_result: Optional[Any] = Field(default=None, exclude=True, description="Explicit public runtime result") + attempt_outcome: Optional[str] = Field(default=None, description="Typed worker outcome for the application host") + query: str = Field(description="User query") + model_config_list: List[ModelConfig] = Field(description="List of model configurations") + observer: MessageObserver = Field(description="Return data") + agent_config: AgentConfig = Field(description="Detailed Agent configuration") + mcp_host: Optional[List[Union[str, Dict[str, Any]]]] = Field( + description="MCP server address(es). Can be a string (URL) or dict with 'url', 'transport', " + "and optionally 'authorization', 'headers', or 'httpx_client_factory' keys. " + "Transport can be 'sse' or 'streamable-http'. If string, transport is auto-detected based on URL ending: " + "URLs ending with '/sse' use 'sse' transport, URLs ending with '/mcp' use 'streamable-http' transport. " + "Authorization can be provided as 'authorization' (e.g., 'Bearer token') or as 'headers' dict.", + default=None, + ) + mcp_request_timeout_seconds: Optional[float] = Field( + description=( + "Maximum seconds allowed to establish an MCP connection and complete " + "the initialization handshake. It does not limit tool execution." + ), + default=None, + ) + history: Optional[List[AgentHistory]] = Field(description="Historical conversation information", default=None) + stop_event: Event = Field(description="Stop event control") + cancellation_scope: Optional[Any] = Field( + description="Run-scoped cancellation and active resource registry", + default=None, + exclude=True, + ) + mcp_tool_timeout_seconds: float = Field( + description="Hard timeout for one MCP tool invocation", + default=60.0, + gt=0, + ) + mcp_close_timeout_seconds: float = Field( + description="Maximum wait for an MCP context to close", + default=5.0, + gt=0, + ) + thread_manager: Optional[Any] = Field( + description="Injected process-local thread manager", + default=None, + exclude=True, + ) + thread_execution_id: Optional[str] = Field( + description="Process-local managed execution identifier", + default=None, + exclude=True, + ) + thread_future: Optional[Any] = Field( + description="Process-local managed execution completion handle", + default=None, + exclude=True, + ) + conversation_id: Optional[int] = Field(description="Conversation id for run-scoped persistence", default=None) + user_id: Optional[str] = Field(description="User id for run-scoped persistence", default=None) + user_context: Optional[Dict[str, Any]] = Field( + description="Caller user context (tenant/user/groups) passed through to tools for " + "tool-side authorization. Hidden from the model; values come only from the " + "authenticated session, never from model output.", + default=None, + exclude=True, + ) + runtime_metadata: Dict[str, Any] = Field( + description="Immutable application-resolved runtime metadata snapshot", + default_factory=dict, + ) + runtime_metadata_version: Optional[int] = Field( + description="Conversation runtime metadata version used by this run", + default=None, + ) + context_input: Optional[Any] = Field( + description="Immutable run-scoped context snapshot supplied by the application boundary.", + default=None, + ) + capacity_snapshot: Optional[Dict[str, Any]] = Field( + description="Resolved model capacity snapshot fields for request monitoring", + default=None, + ) + context_budget_snapshot: Optional[ContextBudgetSnapshot] = Field( + description="Resolved canonical W2 context budget snapshot for request execution", + default=None, + ) + enable_planning: bool = Field(description="Whether to enable the planning phase before execution", default=False) + redis_client: Optional[Any] = Field( + description="Redis client for plan persistence. " + "If provided, plan_repo will use Redis as primary storage with local fallback.", + default=None, + ) + sandbox_config: Optional[Any] = Field( + description=( + "Resolved SandboxConfig for sandbox isolation. " + "Populated by the backend service layer from AgentConfig.sandbox_policy " + "and NEXENT_SANDBOX_* environment variables. " + "When None the SDK uses LocalPythonExecutor (backwards-compatible)." + ), + default=None, + ) + tool_fastpath_enabled: bool = Field( + description="Enable restricted official-tool actions before ordinary Docker initialization.", + default=True, + ) + tool_fastpath_allowed_tools: Optional[List[str]] = Field( + description="Deployment-approved official implementation IDs for local fast-path actions.", + default=None, + ) + minio_client: Optional[Any] = Field( + description=( + "MinIO client for syncing sandbox output files to object storage. " + "Required when sandbox_config.auto_sync_outputs is True." + ), + default=None, + ) + workspace_path: Optional[str] = Field( + description="Run-scoped local workspace used for uploaded inputs and generated outputs.", + default=None, + ) + workspace_run_id: Optional[str] = Field( + description="Opaque run identifier used to isolate workspace files and object keys.", + default=None, + ) + tenant_id: Optional[str] = Field( + description="Tenant id used for run-scoped file isolation.", + default=None, + ) + minio_files: Optional[List[Dict[str, Any]]] = Field( + description="Authorized files uploaded with the current run request.", + default=None, + ) + + class Config: + arbitrary_types_allowed = True diff --git a/sdk/nexent/core/agents/core_adapters/__init__.py b/sdk/nexent/core/agents/core_adapters/__init__.py deleted file mode 100644 index bf02e6b69c..0000000000 --- a/sdk/nexent/core/agents/core_adapters/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Pluggable execution cores for Nexent agent configurations.""" - -from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig -from .factory import AgentCoreFactory - - -__all__ = ["AgentCoreAdapter", "AgentCoreFactory", "UnsupportedAgentCoreConfig"] diff --git a/sdk/nexent/core/agents/cores/__init__.py b/sdk/nexent/core/agents/cores/__init__.py new file mode 100644 index 0000000000..1008dd3774 --- /dev/null +++ b/sdk/nexent/core/agents/cores/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Agent cores namespace; implementations load on demand.""" diff --git a/sdk/nexent/core/agents/cores/jiuwen/__init__.py b/sdk/nexent/core/agents/cores/jiuwen/__init__.py new file mode 100644 index 0000000000..d227fc7114 --- /dev/null +++ b/sdk/nexent/core/agents/cores/jiuwen/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Agent cores jiuwen namespace; implementations load on demand.""" diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen.py b/sdk/nexent/core/agents/cores/jiuwen/adapter.py similarity index 91% rename from sdk/nexent/core/agents/core_adapters/jiuwen.py rename to sdk/nexent/core/agents/cores/jiuwen/adapter.py index 9b01e21943..6407509c39 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen.py +++ b/sdk/nexent/core/agents/cores/jiuwen/adapter.py @@ -12,15 +12,16 @@ from contextvars import ContextVar from types import SimpleNamespace -from ...concurrency import ManagedTaskSpec, RunCancellationScope -from ...concurrency.cancellation import RunTerminated -from ...tools.prompt_registry import PromptToolPolicySnapshot, filter_effective_prompt_tools -from ...utils.observer import ProcessType -from ..context.models import ContextItem, ContextItemType -from ..context.rendering import ContextItemRenderer -from ..nexent_agent import NexentAgent, _wrap_tool_with_monitoring -from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig -from .events import JiuwenEventAdapter +from nexent.core.agents.context.models import ContextItem, ContextItemType +from nexent.core.agents.context.rendering import ContextItemRenderer +from nexent.core.agents.contracts.adapter import AgentCoreAdapter, UnsupportedAgentCoreConfig +from nexent.core.agents.cores.jiuwen.events import JiuwenEventAdapter +from nexent.core.agents.resources.monitoring import _wrap_tool_with_monitoring +from nexent.core.agents.resources.provider import AgentResources +from nexent.core.concurrency import ManagedTaskSpec, RunCancellationScope +from nexent.core.concurrency.cancellation import RunTerminated +from nexent.core.tools.prompt_registry import PromptToolPolicySnapshot, filter_effective_prompt_tools +from nexent.core.utils.observer import ProcessType logger = logging.getLogger(__name__) @@ -119,7 +120,7 @@ def validate(self): if self.model_config is None: raise ValueError(f"Model alias not found: {config.model_name}") profile = self.model_config.resolved_profile - from ...model_service.profiles.resolved_profile import ResolvedModelProfile + from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile if not isinstance(profile, ResolvedModelProfile): raise UnsupportedAgentCoreConfig("Jiuwen requires a trusted resolved model outlet snapshot") @@ -135,7 +136,7 @@ def validate(self): registry = {tool.name or tool.class_name: tool for tool in self.tool_configs} if len(registry) != len(self.tool_configs): raise ValueError("Duplicate tool names in agent configuration") - from .jiuwen_protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL + from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL reserved = {EXECUTION_TOOL, NATIVE_EXECUTION_TOOL} if reserved.intersection(registry): @@ -195,19 +196,21 @@ def prepare(self): from openjiuwen.core.single_agent import AgentCard from openjiuwen.harness.factory import create_deep_agent - from ..run_agent import _get_default_agent_thread_manager + from nexent.core.agents.orchestration.children import child_invocation_factory + from nexent.core.agents.orchestration.execution import _get_default_agent_thread_manager info = self.run_info self.manager = info.thread_manager or _get_default_agent_thread_manager() self.scope = info.cancellation_scope or RunCancellationScope(info.stop_event) if self.scope.cancelled: raise RunTerminated() - self.resources = NexentAgent( + self.resources = AgentResources( observer=info.observer, model_config_list=info.model_config_list, stop_event=info.stop_event, redis_client=info.redis_client, sandbox_config=info.sandbox_config, minio_client=info.minio_client, conversation_id=info.conversation_id, user_id=info.user_id, tenant_id=info.tenant_id, workspace_path=info.workspace_path, workspace_run_id=info.workspace_run_id, minio_files=info.minio_files, cancellation_scope=self.scope, user_context=info.user_context, + child_invocation_factory=child_invocation_factory(info), ) self.resources.core_run_info = info native_tools = [] @@ -230,8 +233,8 @@ def prepare(self): self._business_handler(tool.name, tool, lane="control-io"), )) for external in info.agent_config.external_a2a_agents: - from ..a2a_agent_proxy import ExternalA2AAgentWrapper - from ..subagent_wrapper import SubAgentDefinition + from nexent.core.agents.resources.a2a import ExternalA2AAgentWrapper + from nexent.core.agents.resources.subagents import SubAgentDefinition definition = SubAgentDefinition(external.name, external.description or external.name) @@ -260,10 +263,10 @@ def invoke_remote(task, _config=external): prompt = self._compile_context() rails = self._skill_rails() model = self._create_model() - from .jiuwen_context import JiuwenContextBridge + from nexent.core.agents.cores.jiuwen.context import JiuwenContextBridge self.context_bridge = JiuwenContextBridge(info, self.scope, self.manager, model, self.context_items) - model.prepare_messages = self.context_bridge.prepare_messages + model.prepare_messages = self._prepare_model_messages model.prefix = [] self.native_agent = create_deep_agent( model, card=AgentCard(id=self.owner_id, name=info.agent_config.name, @@ -325,7 +328,7 @@ async def execute(**arguments): return execute def _compile_context(self): - from ..run_agent import _get_authorized_context_items, _get_authorized_history + from nexent.core.agents.orchestration.execution import _get_authorized_context_items, _get_authorized_history config = self.run_info.agent_config instructions = [config.instructions or ""] @@ -378,10 +381,10 @@ def _compile_context(self): return prompt + "\n\n" + current_task_guidance + "\n\n" + resource_guidance def _create_model(self): - from ...model_service.registry import AuthenticationRegistry - from ...model_service.service import ModelService - from ...utils.token_estimation import estimate_tokens_text - from .jiuwen_model import JiuwenServiceModel + from nexent.core.agents.cores.jiuwen.model import JiuwenServiceModel + from nexent.core.model_service.registry import AuthenticationRegistry + from nexent.core.model_service.service import ModelService + from nexent.core.utils.token_estimation import estimate_tokens_text info, config = self.run_info, self.model_config profile = config.resolved_profile @@ -433,8 +436,9 @@ def validate_input(view): def _bind_text_executor(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard - from ..sandbox import SandboxConfig, _execute_with_tool_context, build_python_executor - from .jiuwen_protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL + from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL + from nexent.core.agents.sandbox.config import SandboxConfig + from nexent.core.agents.sandbox.execution import _execute_with_tool_context, build_python_executor executor = self.resources.agent.python_executor if executor is None: @@ -463,7 +467,7 @@ def execute(code): ) def _skill_rails(self): - from .skills import NexentSkillRail + from nexent.core.agents.cores.jiuwen.skills import NexentSkillRail directories, names, authorized_by_root = set(), set(), {} for config in self.tool_configs: @@ -481,7 +485,8 @@ def _skill_rails(self): if directories and names else [] def _bind_skill_sandbox(self): - from ..sandbox import SandboxLevel, SandboxSkillScriptRunner, build_python_executor + from nexent.core.agents.sandbox.config import SandboxLevel + from nexent.core.agents.sandbox.execution import SandboxSkillScriptRunner, build_python_executor config = self.run_info.sandbox_config if config is None or config.level == SandboxLevel.LOCAL: @@ -502,7 +507,7 @@ def _bind_skill_sandbox(self): def _bind_parallel_tool(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard - from ...tools.parallel_executor import ParallelExecutorTool + from nexent.core.tools.parallel_executor import ParallelExecutorTool # Reuse the existing scheduler/results/timeout contract. Bridge waiters # use control-io; actual resource effects still use model-tool-io. @@ -534,6 +539,15 @@ def parallel(tasks, timeout=None, max_workers=4): card, self._business_handler("parallel_executor", parallel, lane="background-service")), ) + async def _prepare_model_messages(self, messages, tools): + """Stop native loop retries at the model boundary after a resource failure.""" + if self.mcp is not None and self.mcp.terminal_error is not None: + raise self.mcp.terminal_error + prepared = await self.context_bridge.prepare_messages(messages, tools) + if self.mcp is not None and self.mcp.terminal_error is not None: + raise self.mcp.terminal_error + return prepared + def run(self): try: answer = asyncio.run(self._run_async()) @@ -548,12 +562,12 @@ def run(self): async def _run_async(self): from openjiuwen.core.runner import Runner - from .mcp import JiuwenMCPCollection + from nexent.core.agents.cores.jiuwen.mcp import JiuwenMCPCollection info = self.run_info current = asyncio.current_task() loop = asyncio.get_running_loop() - from .jiuwen_resources import JiuwenResourceBridge + from nexent.core.agents.cores.jiuwen.resources import JiuwenResourceBridge self.resource_bridge = JiuwenResourceBridge(self.native_agent, loop, self.scope, self.visible_tool_names) token = self.scope.register_closer(lambda: loop.call_soon_threadsafe(current.cancel)) @@ -642,9 +656,4 @@ def _close_resources(self): self.mcp.close() finally: if self.resources: - self.resources._close_subagent_invocations() - try: - self.resources._finalize_file_workspace() - finally: - self.resources._cleanup_file_workspace() - self.resources._cleanup_sandbox() + self.resources.close() diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_context.py b/sdk/nexent/core/agents/cores/jiuwen/context.py similarity index 92% rename from sdk/nexent/core/agents/core_adapters/jiuwen_context.py rename to sdk/nexent/core/agents/cores/jiuwen/context.py index c10eac698c..7572516244 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen_context.py +++ b/sdk/nexent/core/agents/cores/jiuwen/context.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Adapt native messages to the existing ContextManager without synthetic Agent memory.""" import asyncio @@ -7,14 +9,15 @@ from copy import copy from types import SimpleNamespace -from ...agent_runtime_support.output import ContextCapacityObservation -from ...concurrency import ManagedTaskSpec -from ...concurrency.cancellation import RunTerminated -from ...model_service.compatibility.invocation_options import LegacyInvocationOptions -from ...utils.observer import ProcessType -from ..context import ContextItem, ContextItemInput, ContextManager, ContextManagerConfig -from ..context.models import CurrentActionContextItem -from .jiuwen_messages import _mapping, resource_view +from nexent.core.agents.context import ContextItem, ContextManager, ContextManagerConfig +from nexent.core.agents.context.models import CurrentActionContextItem +from nexent.core.agents.contracts.context import ContextItemInput +from nexent.core.agents.contracts.output import ContextCapacityObservation +from nexent.core.agents.cores.jiuwen.messages import _mapping, resource_view +from nexent.core.concurrency import ManagedTaskSpec +from nexent.core.concurrency.cancellation import RunTerminated +from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions +from nexent.core.utils.observer import ProcessType logger = logging.getLogger(__name__) diff --git a/sdk/nexent/core/agents/core_adapters/events.py b/sdk/nexent/core/agents/cores/jiuwen/events.py similarity index 96% rename from sdk/nexent/core/agents/core_adapters/events.py rename to sdk/nexent/core/agents/cores/jiuwen/events.py index cfe6510dd8..9b481e2ae7 100644 --- a/sdk/nexent/core/agents/core_adapters/events.py +++ b/sdk/nexent/core/agents/cores/jiuwen/events.py @@ -6,10 +6,10 @@ import time import uuid -from ...agent_runtime_support.answer import AnswerBoundaryError, PublicTextFilter, normalize_public_answer -from ...agent_runtime_support.output import RunResult, StepObservation -from ...model_service.contracts import ModelError -from ...utils.observer import ProcessType +from nexent.core.agents.contracts.output import RunResult, StepObservation +from nexent.core.agents.output.answer import AnswerBoundaryError, PublicTextFilter, normalize_public_answer +from nexent.core.model_service.contracts import ModelError +from nexent.core.utils.observer import ProcessType class JiuwenEventAdapter: diff --git a/sdk/nexent/core/agents/core_adapters/mcp.py b/sdk/nexent/core/agents/cores/jiuwen/mcp.py similarity index 95% rename from sdk/nexent/core/agents/core_adapters/mcp.py rename to sdk/nexent/core/agents/cores/jiuwen/mcp.py index 8344850133..6f31c73dd6 100644 --- a/sdk/nexent/core/agents/core_adapters/mcp.py +++ b/sdk/nexent/core/agents/cores/jiuwen/mcp.py @@ -13,10 +13,10 @@ from openjiuwen.core.foundation.tool.mcp.base import McpServerConfig, MCPTool, McpToolCard from openjiuwen.core.foundation.tool.mcp.client.streamable_http_client import StreamableHttpClient -from ....consts.mcp_errors import MCPConnectionTimeoutError, MCPToolTimeoutError -from ...concurrency import ManagedTaskSpec -from ..managed_mcp import ManagedMCPToolCollection -from ..tool_user_context import USER_CONTEXT_FIELDS +from nexent.consts.mcp_errors import MCPConnectionTimeoutError, MCPToolTimeoutError +from nexent.core.agents.resources.mcp import ManagedMCPToolCollection +from nexent.core.agents.resources.tool_user_context import USER_CONTEXT_FIELDS +from nexent.core.concurrency import ManagedTaskSpec logger = logging.getLogger(__name__) @@ -44,7 +44,7 @@ def __init__(self, *, authorized_tools, observe, user_context=None, emit_result= self.user_context = deepcopy(dict(user_context or {})) async def _run_async_session(self): - from ..run_agent import _normalize_mcp_config + from nexent.core.agents.orchestration.entry import _normalize_mcp_config async with AsyncExitStack() as stack: available = {} diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_messages.py b/sdk/nexent/core/agents/cores/jiuwen/messages.py similarity index 91% rename from sdk/nexent/core/agents/core_adapters/jiuwen_messages.py rename to sdk/nexent/core/agents/cores/jiuwen/messages.py index 7ba7f7876c..d10195f79c 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen_messages.py +++ b/sdk/nexent/core/agents/cores/jiuwen/messages.py @@ -1,11 +1,13 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Translate Jiuwen messages into the public, non-executable resource view.""" import json from copy import deepcopy -from ...agent_runtime_support.contracts import ResourceItem, RuntimeResourceView -from ...model_service.contracts import ModelMessage, ToolCall, ToolDefinition -from .jiuwen_protocol import EXECUTION_TOOL, PROTOCOL_INSTRUCTION, JiuwenProtocolError +from nexent.core.agents.contracts.resources import ResourceItem, RuntimeResourceView +from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, PROTOCOL_INSTRUCTION, JiuwenProtocolError +from nexent.core.model_service.contracts import ModelMessage, ToolCall, ToolDefinition def _mapping(value): diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_model.py b/sdk/nexent/core/agents/cores/jiuwen/model.py similarity index 95% rename from sdk/nexent/core/agents/core_adapters/jiuwen_model.py rename to sdk/nexent/core/agents/cores/jiuwen/model.py index 1bd2fbdb4e..3863118d39 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen_model.py +++ b/sdk/nexent/core/agents/cores/jiuwen/model.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Jiuwen's model interface over Nexent's single model transport service.""" import json @@ -14,13 +16,13 @@ from openjiuwen.core.foundation.llm.schema.message_chunk import AssistantMessageChunk from openjiuwen.core.foundation.llm.schema.tool_call import ToolCall as NativeToolCall -from ...concurrency.cancellation import RunTerminated -from ...model_service.contracts import ModelError -from ...model_service.profiles.resolved_profile import ResolvedModelProfile -from ...model_service.resource_conversion import ResourceConverterRegistry -from ...model_service.service import ModelServiceError -from .jiuwen_messages import resource_view -from .jiuwen_protocol import EXECUTION_TOOL, JiuwenProtocolError, parse_execution_blocks +from nexent.core.agents.cores.jiuwen.messages import resource_view +from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, JiuwenProtocolError, parse_execution_blocks +from nexent.core.concurrency.cancellation import RunTerminated +from nexent.core.model_service.contracts import ModelError +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.resource_conversion import ResourceConverterRegistry +from nexent.core.model_service.service import ModelServiceError _ARGUMENT_ERRORS = frozenset({ diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py b/sdk/nexent/core/agents/cores/jiuwen/protocol.py similarity index 100% rename from sdk/nexent/core/agents/core_adapters/jiuwen_protocol.py rename to sdk/nexent/core/agents/cores/jiuwen/protocol.py diff --git a/sdk/nexent/core/agents/core_adapters/jiuwen_resources.py b/sdk/nexent/core/agents/cores/jiuwen/resources.py similarity index 95% rename from sdk/nexent/core/agents/core_adapters/jiuwen_resources.py rename to sdk/nexent/core/agents/cores/jiuwen/resources.py index 1191f30f71..c23db80a17 100644 --- a/sdk/nexent/core/agents/core_adapters/jiuwen_resources.py +++ b/sdk/nexent/core/agents/cores/jiuwen/resources.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Expose authorized Jiuwen abilities to the existing controlled Python executor.""" import asyncio @@ -6,7 +8,7 @@ from jsonschema import validate -from ...concurrency.cancellation import RunTerminated +from nexent.core.concurrency.cancellation import RunTerminated class JiuwenResourceBridge: diff --git a/sdk/nexent/core/agents/core_adapters/skills.py b/sdk/nexent/core/agents/cores/jiuwen/skills.py similarity index 96% rename from sdk/nexent/core/agents/core_adapters/skills.py rename to sdk/nexent/core/agents/cores/jiuwen/skills.py index 7b0d900562..0b549c7b89 100644 --- a/sdk/nexent/core/agents/core_adapters/skills.py +++ b/sdk/nexent/core/agents/cores/jiuwen/skills.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Load authorized skill snapshots with Jiuwen's native skill rail.""" from pathlib import Path diff --git a/sdk/nexent/core/agents/cores/smolagents/__init__.py b/sdk/nexent/core/agents/cores/smolagents/__init__.py new file mode 100644 index 0000000000..ed5420fe02 --- /dev/null +++ b/sdk/nexent/core/agents/cores/smolagents/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Agent cores smolagents namespace; implementations load on demand.""" diff --git a/sdk/nexent/core/agents/core_adapters/smolagents.py b/sdk/nexent/core/agents/cores/smolagents/adapter.py similarity index 75% rename from sdk/nexent/core/agents/core_adapters/smolagents.py rename to sdk/nexent/core/agents/cores/smolagents/adapter.py index 40e7d31331..ee4be1734e 100644 --- a/sdk/nexent/core/agents/core_adapters/smolagents.py +++ b/sdk/nexent/core/agents/cores/smolagents/adapter.py @@ -1,6 +1,8 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Compatibility adapter for the existing smolagents execution path.""" -from .base import AgentCoreAdapter +from nexent.core.agents.contracts.adapter import AgentCoreAdapter class SmolagentsAdapter(AgentCoreAdapter): @@ -20,6 +22,6 @@ def validate(self): def run(self): if self.executor is not None: return self.executor(self.run_info) - from ..run_agent import _run_smolagents + from nexent.core.agents.cores.smolagents.runner import _run_smolagents return _run_smolagents(self.run_info) diff --git a/sdk/nexent/core/agents/cores/smolagents/agent.py b/sdk/nexent/core/agents/cores/smolagents/agent.py new file mode 100644 index 0000000000..642c65448d --- /dev/null +++ b/sdk/nexent/core/agents/cores/smolagents/agent.py @@ -0,0 +1,1004 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + + +from __future__ import annotations + +import json +import logging +import re +import time +from concurrent.futures import CancelledError +from contextlib import contextmanager +from copy import deepcopy +from dataclasses import replace +from threading import RLock +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence + +from smolagents import ActionStep, AgentText, TaskStep, Timing +from smolagents.tools import Tool + +from nexent.consts.mcp_errors import is_mcp_timeout_error +from nexent.core.agents.contracts.clarification import choose_clarification_tool_name +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.contracts.run import AgentHistory +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent, convert_code_format +from nexent.core.agents.cores.smolagents.output_protocol import ModelOutputProtocolExhaustedError +from nexent.core.agents.prompt.clarification import clarification_policy +from nexent.core.agents.resources.monitoring import ( + _build_tool_input, + _is_retriever_tool, + _tool_name, + _wrap_tool_with_monitoring, +) +from nexent.core.agents.resources.provider import AgentResources +from nexent.core.agents.resources.tool_user_context import ( + apply_model_visible_tool_schemas_to_context_items, +) +from nexent.core.agents.resources.workspace import cleanup_run_workspace +from nexent.core.model_errors import ModelInvocationTerminalError +from nexent.core.models.openai_llm import OpenAIModel +from nexent.core.tools import * +from nexent.core.utils.constants import THINK_PREFIX_PATTERN, THINK_TAG_PATTERN +from nexent.core.utils.observer import ProcessType +from nexent.monitor import AgentRunMetadata, get_agent_monitoring_context, get_monitoring_manager + + +if TYPE_CHECKING: + from nexent.core.agents.context import ContextItemInput + from nexent.core.agents.resources.subagents import SubAgentToolWrapper as SubAgentToolWrapper + + +# Safe base imports for Python interpreter - excludes file modification and system access modules +SAFE_PYTHON_INTERPRETER_IMPORTS = [ + "math", "cmath", "statistics", "decimal", "fractions", "random", + "collections", "itertools", "functools", "heapq", "bisect", "array", "copy", + "re", "string", "textwrap", "unicodedata", + "datetime", "time", "calendar", + "base64", "hashlib", "hmac", + "json", "csv", + "uuid", "pprint", "operator", "typing", +] + + +def get_local_python_authorized_imports() -> List[str]: + """Return the imports permitted by Nexent's default local code executor.""" + from smolagents.local_python_executor import BASE_BUILTIN_MODULES + + return sorted(set(BASE_BUILTIN_MODULES) | set(SAFE_PYTHON_INTERPRETER_IMPORTS)) + + +logger = logging.getLogger(__name__) + + +def _ensure_non_empty_final_answer(answer: str, lang: str) -> str: + """Return a user-visible fallback when final-answer cleanup removes all content.""" + if answer.strip(): + return answer + logger.warning("Final answer was empty after removing reasoning content") + if lang == "zh": + return "智能体未能生成有效的最终回复,请重试或换一种方式描述需求。" + return "The agent could not generate a valid final response. Please try again or rephrase your request." + + +def _has_host_tools(tools: List[Any]) -> bool: + """Return whether the agent has tools marked for host-process execution.""" + return any(getattr(tool, "_nexent_execute_on_host", False) for tool in tools) + + +class NexentAgent(AgentResources): + """smolagents execution assembled from shared authorized resources.""" + + def create_model(self, model_cite_name: str): + """create a model instance""" + # Filter out None values and find matching model config + model_config = next( + (model_config for model_config in self.model_config_list + if model_config is not None and model_config.cite_name == model_cite_name), + None + ) + if model_config is None: + raise ValueError(f"Model {model_cite_name} not found") + model_kwargs = dict( + observer=self.observer, + model_id=model_config.model_name, + api_key=model_config.api_key, + api_base=model_config.url, + temperature=model_config.temperature, + top_p=model_config.top_p, + ssl_verify=model_config.ssl_verify if model_config.ssl_verify is not None else True, + model_factory=model_config.model_factory, + display_name=model_config.cite_name, + extra_body=model_config.extra_body, + max_output_tokens=model_config.max_output_tokens, + timeout_seconds=model_config.timeout_seconds, + prompt_cache=model_config.prompt_cache, + reasoning_capability=model_config.reasoning_capability, + diagnostic_chunks_enabled=model_config.diagnostic_chunks_enabled, + ) + if model_config.explicit_sampling: + model_kwargs["explicit_sampling"] = model_config.explicit_sampling + if model_config.reasoning_budget_tokens is not None: + model_kwargs["reasoning_budget_tokens"] = model_config.reasoning_budget_tokens + if ( + model_config.enable_thinking + and model_config.reasoning_effort is not None + and model_config.reasoning_effort != "auto" + ): + model_kwargs["reasoning_effort"] = model_config.reasoning_effort + if self.cancellation_scope is not None: + model_kwargs["cancellation_scope"] = self.cancellation_scope + if model_config.concurrency_limit is not None: + model_kwargs["concurrency_limit"] = model_config.concurrency_limit + model_kwargs["concurrency_key"] = ( + str(self.tenant_id or "default"), + str(model_config.model_factory or "unknown"), + str(model_config.model_name), + ) + model = OpenAIModel(**model_kwargs) + model.stop_event = self.stop_event + return model + + + def create_single_agent( + self, + agent_config: AgentConfig, + _managed_context: bool = False, + *, + context_items_override: Sequence["ContextItemInput"] | None = None, + _sandbox_tree_context: Optional[Dict[str, Any]] = None, + ) -> CoreAgent: + """ + Build a CoreAgent from ``agent_config``. + + Args: + agent_config: AgentConfig describing this agent. + _managed_context: Internal compatibility flag for managed agents. + _sandbox_tree_context: Internal construction context used to share + one session Docker container across the agent tree while each + agent retains an isolated kernel. + """ + if not isinstance(agent_config, AgentConfig): + raise TypeError("agent_config must be a AgentConfig object") + if _sandbox_tree_context is None: + _sandbox_tree_context = {} + _sandbox_tree_context.setdefault("initialization_lock", RLock()) + + try: + self._check_sandbox_cancelled() + model = self.create_model(agent_config.model_name) + model.context_budget_snapshot = getattr( + agent_config, + "context_budget_snapshot", + None, + ) + model.capacity_snapshot = getattr( + agent_config, + "capacity_snapshot", + None, + ) + prompt_templates = agent_config.prompt_templates + + prompt_tool_policy = getattr(agent_config, "prompt_tool_policy_snapshot", None) + effective_prompt_tools = None + tool_configs = agent_config.tools + if prompt_tool_policy is not None: + from nexent.core.tools.prompt_registry import PromptToolPolicySnapshot, filter_effective_prompt_tools + + snapshot = PromptToolPolicySnapshot.from_mapping(prompt_tool_policy) + registry = {config.name: config for config in agent_config.tools if config.name} + if len(registry) != len(agent_config.tools): + raise ValueError("Agent prompt tool registry has duplicate or unnamed entries") + effective_prompt_tools = filter_effective_prompt_tools( + registry, enabled=set(snapshot.enabled), allowed=set(snapshot.allowed), + system_default_hidden=set(snapshot.system_default_hidden), + policy_version=snapshot.policy_version, + tool_schema_version=snapshot.tool_schema_version, + ) + tool_configs = list(effective_prompt_tools.execution_tools.values()) + + try: + tool_list = [ + _wrap_tool_with_monitoring( + self.create_tool(tool_config), + agent_config.name, + ) + for tool_config in tool_configs + ] + except Exception as e: + raise ValueError(f"Error in creating tool: {e}") + + try: + # Definitions are lazy; each invocation owns its runtime and kernel. + worker_agents_list = [ + self._create_subagent_definition(sub_agent_config, _sandbox_tree_context) + for sub_agent_config in agent_config.worker_agents + ] + except Exception as e: + raise ValueError(f"Error in creating worker agent: {e}") + + # Create wrapper agents for external A2A agents in the worker-agent call set. + # so model can call them like: external_agent_name(task="...") + if agent_config.external_a2a_agents: + try: + from nexent.core.agents.resources.a2a import ExternalA2AAgentWrapper + for ext_agent_config in agent_config.external_a2a_agents: + a2a_agent_info = ext_agent_config.to_a2a_agent_info() + wrapper = ExternalA2AAgentWrapper( + agent_info=a2a_agent_info, + stop_event=self.stop_event, + observer=self.observer, + cancellation_scope=self.cancellation_scope, + user_context=self.user_context, + ) + worker_agents_list.append( + self._wrap_subagent( + wrapper, + ext_agent_config, + agent_id=str(ext_agent_config.agent_id), + ) + ) + except Exception as e: + raise ValueError(f"Error in creating external A2A agent wrapper: {e}") + + # ContextManager is the only production context assembly path. + # ContextPolicy controls whether adaptive compaction is active. + from nexent.core.agents.context import ContextManager, ContextManagerConfig + from nexent.core.agents.cores.smolagents.context import ManagedContextRuntime + + ctx_config = ( + getattr(agent_config, "context_manager_config", None) + or ContextManagerConfig() + ) + context_manager = ContextManager( + config=ctx_config, + max_steps=agent_config.max_steps, + ) + context_items = apply_model_visible_tool_schemas_to_context_items( + list(context_items_override) + if context_items_override is not None + else list(getattr(agent_config, "context_items", None) or []), + tool_list, + ) + from nexent.core.agents.context import ContextItemInput, ContextItemType + + enable_clarification = ( + not _managed_context + and getattr(self.observer, "enable_nl2a_wrapper", False) is not True + and getattr(agent_config, "output_protocol", "code_action") == "code_action" + ) + if enable_clarification and any(item.type == ContextItemType.SYSTEM for item in context_items): + tool_name = choose_clarification_tool_name({tool.name for tool in [*tool_list, *worker_agents_list]}) + context_items.append(ContextItemInput( + id="system:clarification_protocol", + type=ContextItemType.SYSTEM, + content={ + "text": clarification_policy( + tool_name, + language=getattr(self.observer, "lang", "en"), + ) + }, + source=("runtime:clarification_protocol",), + priority=100, + metadata={"authority": "platform", "layout_order": 23}, + )) + if effective_prompt_tools is not None: + visible_names = set(effective_prompt_tools.tools) + context_items = [ + item for item in context_items + if getattr(getattr(item, "type", None), "value", getattr(item, "type", None)) != "tool" + or item.id.removeprefix("tool:") in visible_names + ] + self.observer.add_message( + agent_config.name, + ProcessType.OTHER, + json.dumps({"event": "prompt_tool_registry", **dict(effective_prompt_tools.audit)}), + ) + context_runtime = ManagedContextRuntime( + context_manager, + items=context_items, + ) + + # Build one code executor for this agent. Worker-agent orchestration + # is a host-marked tool, so every agent needs its own kernel to avoid + # nested execution deadlocks; session containers can still be shared. + python_executor = None + if self.sandbox_config is not None: + from nexent.core.agents.sandbox.config import SandboxLevel + + deferred = self.tool_fastpath_enabled and self.sandbox_config.level == SandboxLevel.DOCKER + self._sandbox_scope = self.sandbox_config.scope.value + + def release_executor(executor): + with _sandbox_tree_context["initialization_lock"]: + self._release_deferred_executor(executor) + group = _sandbox_tree_context.get("session_container_group") + if group is not None and getattr(group, "_closed", False): + _sandbox_tree_context.pop("session_container_group", None) + + def create_executor(): + python_executor = None + script_runner = None + with _sandbox_tree_context["initialization_lock"]: + try: + from nexent.core.agents.sandbox.execution import build_python_executor + self._check_sandbox_cancelled() + if deferred and self.workspace_path: + self._pull_file_workspace_from_sandbox() + has_worker = bool( + agent_config.worker_agents + or getattr(agent_config, "external_a2a_agents", []) + ) + python_executor = build_python_executor( + config=self.sandbox_config, + logger_=logger, + worker_agents_exist=has_worker, + host_tools_exist=_has_host_tools([ + *tool_list, + *worker_agents_list, + ]), + session_container_group=_sandbox_tree_context.get( + "session_container_group" + ), + cancellation_scope=self.cancellation_scope, + ) + self._sandbox_executors.append(python_executor) + self._check_sandbox_cancelled() + session_container_group = None + if ( + self.sandbox_config.level == SandboxLevel.DOCKER + and self.sandbox_config.scope.value == "session" + ): + session_container_group = getattr( + python_executor, + "_nexent_session_container_group", + None, + ) + if session_container_group is not None: + existing_group = _sandbox_tree_context.setdefault( + "session_container_group", + session_container_group, + ) + if existing_group is not session_container_group: + raise RuntimeError( + "Agent tree received multiple session sandbox containers" + ) + if self.sandbox_config.level != SandboxLevel.LOCAL: + from nexent.core.agents.sandbox.execution import SandboxSkillScriptRunner + + configured_timeout = getattr(self.sandbox_config, "timeout_seconds", None) + skill_timeout = ( + max(1, int(configured_timeout)) + if isinstance(configured_timeout, (int, float)) + and not isinstance(configured_timeout, bool) + else 300 + ) + script_runner = SandboxSkillScriptRunner( + python_executor, + timeout_seconds=skill_timeout, + workspace_path=self.workspace_path, + network_enabled=not self.sandbox_config.network_disabled, + workspace_mapping=getattr(self, "workspace_mapping", None), + ) + for tool in tool_list: + bind_backend = getattr(tool, "bind_execution_backend", None) + if callable(bind_backend) and _tool_name(tool) == "run_skill_script": + bind_backend( + script_runner, + on_complete=lambda _result: self._pull_file_workspace_from_sandbox(), + ) + self._sandbox_skill_runners.append(script_runner) + self._check_sandbox_cancelled() + # Warm up remote executors when they are acquired. + if self.sandbox_config.level != SandboxLevel.LOCAL: + try: + warm_start = time.time() + current_metadata = get_agent_monitoring_context() or AgentRunMetadata() + warmup_metadata = replace( + current_metadata, + agent_id=( + getattr(agent_config, "_sub_agent_id", None) + if _managed_context else current_metadata.agent_id + ), + agent_name=agent_config.name, + agent_display_name=agent_config.display_name, + model_name=agent_config.model_name, + ) + with get_monitoring_manager().trace_agent_step( + "agent.sandbox.warmup", + warmup_metadata, + step_type="sandbox_warmup", + **{ + "sandbox.level": self.sandbox_config.level.value, + "sandbox.scope": self.sandbox_config.scope.value, + "sandbox.backend": getattr(python_executor, "_nexent_backend", "unknown"), + }, + ): + self._check_sandbox_cancelled() + python_executor("[0, None]") + self._check_sandbox_cancelled() + warm_dur = time.time() - warm_start + backend = getattr(python_executor, "_nexent_backend", "unknown") + if backend == "local": + if self.sandbox_config.failure_policy == "error": + raise RuntimeError("Docker was requested but the executor is local") + self.workspace_mapping = None + for tool in tool_list: + if getattr(tool, "workspace_mapping", None) is not None: + tool.workspace_mapping = None + self.observer.add_message( + "", ProcessType.WARNING, + "Docker sandbox unavailable; execution has fallen back to LocalPythonExecutor.", + ) + logger.warning( + "Sandbox level '%s' unavailable; using LocalPythonExecutor instead " + "(scope=%s, warm-up %.2fs)", + self.sandbox_config.level.value, + self.sandbox_config.scope.value, + warm_dur, + ) + else: + logger.info( + "Sandbox warmed up in %.2fs (backend=%s, level=%s, scope=%s)", + warm_dur, + backend, + self.sandbox_config.level.value, + self.sandbox_config.scope.value, + ) + except CancelledError: + raise + except Exception as warm_err: + self._check_sandbox_cancelled() + if self.sandbox_config.failure_policy == "error": + raise RuntimeError("Sandbox FAILED phase=warmup") from warm_err + logger.warning( + "Sandbox warm-up failed (%s): %s", + self.sandbox_config.level.value, + warm_err, + ) + # Store scope on NexentAgent so _cleanup_sandbox() can read it. + self._sandbox_scope = self.sandbox_config.scope.value + + if deferred: + self._check_sandbox_cancelled() + container = getattr(python_executor, "container", None) + if container is not None: + self._push_file_workspace_to_sandbox(containers=[container]) + self._initialize_sandbox_workspaces(executors=[python_executor]) + return python_executor + except BaseException: + if deferred and python_executor is not None: + try: + if script_runner is not None: + skill_owner = self._sandbox_skill_cleanup_owner + if skill_owner is self: + script_runner.cleanup() + else: + # A failed child must not remove skills used by sibling invocations. + with skill_owner._subagent_invocations_lock: + skill_owner._sandbox_skill_runners.append(script_runner) + self._sandbox_skill_runners[:] = [ + runner for runner in self._sandbox_skill_runners + if runner is not script_runner + ] + finally: + release_executor(python_executor) + raise + + if deferred: + from nexent.core.agents.cores.smolagents.tool_fastpath import ( + OFFICIAL_FASTPATH_TOOL_CLASSES, + DeferredToolExecutor, + official_fastpath_tools, + ) + + allowed_classes = ( + OFFICIAL_FASTPATH_TOOL_CLASSES + if self.tool_fastpath_allowed_tools is None + else self.tool_fastpath_allowed_tools + ) + python_executor = DeferredToolExecutor( + create_executor=create_executor, + release_executor=release_executor, + allowed_tools=official_fastpath_tools(tool_configs, tool_list, allowed_classes), + stop_event=self.stop_event, + ) + self._tool_fastpath_executors.append(python_executor) + else: + python_executor = create_executor() + + # Create the agent + self._check_sandbox_cancelled() + agent = CoreAgent( + observer=self.observer, + tools=tool_list, + model=model, + name=agent_config.invocation_name or agent_config.name, + display_name=agent_config.display_name, + description=agent_config.description, + max_steps=agent_config.max_steps, + prompt_templates=prompt_templates, + provide_run_summary=agent_config.provide_run_summary, + managed_agents=worker_agents_list, + additional_authorized_imports=SAFE_PYTHON_INTERPRETER_IMPORTS, + instructions=agent_config.instructions, + context_runtime=context_runtime, + enable_planning=agent_config.enable_planning, + redis_client=self.redis_client, + conversation_id=self.conversation_id, + user_id=self.user_id, + executor=python_executor, + verification_config=getattr(agent_config, "verification_config", None), + output_protocol=getattr(agent_config, "output_protocol", "code_action"), + enable_protocol_repair_retry=agent_config.enable_protocol_repair_retry, + enable_clarification=enable_clarification, + workspace_path=self.workspace_path, + ) + agent.stop_event = self.stop_event + + # Wire plan tool deps if the plan tools are present in agent_config.tools. + # CoreAgent already knows enable_planning (set above); use that to gate wiring. + if agent.enable_planning: + if (create_plan := agent.tools.get("create_plan")) is not None: + create_plan.observer = agent.observer + create_plan.plan_repo = agent.plan_repo + create_plan._on_plan_created = agent._on_plan_created + create_plan._get_conversation_id = agent._get_conversation_id + create_plan._get_user_id = agent._get_user_id + if (update_step := agent.tools.get("update_plan_step")) is not None: + update_step.observer = agent.observer + update_step.plan_repo = agent.plan_repo + update_step._on_step_updated = agent._on_step_updated + update_step._get_conversation_id = agent._get_conversation_id + update_step._get_user_id = agent._get_user_id + + return agent + except CancelledError: + self._cleanup_sandbox() + raise + except Exception as e: + if getattr(self.sandbox_config, "failure_policy", None) == "error": + try: + self._cleanup_sandbox() + except Exception: + logger.exception("Failed to release sandbox resources after agent construction failed") + raise ValueError(f"Error in creating agent, agent name: {agent_config.name}, Error: {e}") + + + def add_history_to_agent(self, history: List[AgentHistory]): + """ + Add conversation history to agent's memory + + Args: + history: List of conversation messages with role and content + """ + if history is None: + return + + if not isinstance(self.agent, CoreAgent): + raise TypeError(f"agent must be a CoreAgent object, not {type(self.agent)}") + + if not all(isinstance(msg, AgentHistory) for msg in history): + raise TypeError("history must be a list of AgentHistory objects") + + self.agent.memory.reset() + # Add conversation history to memory sequentially + for msg in history: + if msg.role == 'user': + # Create task step for user message + self.agent.memory.steps.append(TaskStep(task=msg.content)) + elif msg.role == 'assistant': + self.agent.memory.steps.append(ActionStep(step_number=len(self.agent.memory.steps) + 1, + timing=Timing(start_time=time.time()), + action_output=msg.content, model_output=msg.content)) + + self.agent._history_step_count = len(self.agent.memory.steps) + + + @staticmethod + def _set_runtime_metadata_for_agent_tree(root_agent: CoreAgent, metadata: Dict[str, Any]): + """Set isolated runtime metadata on internal and external sub-agents.""" + + snapshots = [] + pending = [root_agent] + visited = set() + while pending: + current = pending.pop() + if not isinstance(current, CoreAgent) or id(current) in visited: + continue + visited.add(id(current)) + snapshots.append(("core", current, "metadata" in current.state, current.state.get("metadata"))) + current.state["metadata"] = deepcopy(metadata) + children = getattr(current, "managed_agents", {}) or {} + if isinstance(children, dict): + child_values = children.values() + elif isinstance(children, (list, tuple)): + child_values = children + else: + child_values = () + for child in child_values: + inner_agent = ( + child + if isinstance(child, CoreAgent) + else getattr(child, "_inner", child) + ) + if isinstance(inner_agent, CoreAgent): + pending.append(inner_agent) + continue + + set_runtime_metadata = getattr(inner_agent, "set_runtime_metadata", None) + get_runtime_metadata = getattr(inner_agent, "get_runtime_metadata", None) + if callable(set_runtime_metadata) and callable(get_runtime_metadata): + snapshots.append(("external", inner_agent, True, get_runtime_metadata())) + set_runtime_metadata(deepcopy(metadata)) + return snapshots + + + @staticmethod + def _restore_runtime_metadata_for_agent_tree(snapshots) -> None: + """Restore agent state after one run, including failure and cancellation.""" + + for agent_type, agent, existed, previous_value in snapshots: + if agent_type == "external": + agent.set_runtime_metadata(previous_value) + elif existed: + agent.state["metadata"] = previous_value + else: + agent.state.pop("metadata", None) + + + def agent_run_with_observer( + self, + query: str, + reset: bool = True, + additional_args: Optional[Dict[str, Any]] = None, + ): + if not isinstance(self.agent, CoreAgent): + raise TypeError(f"agent must be a CoreAgent object, not {type(self.agent)}") + + monitoring_manager = get_monitoring_manager() + current_metadata = get_agent_monitoring_context() or AgentRunMetadata() + metadata = replace( + current_metadata, + agent_name=current_metadata.agent_name or getattr(self.agent, "name", None) or self.agent.agent_name, + agent_display_name=current_metadata.agent_display_name or getattr(self.agent, "display_name", None), + query=current_metadata.query if current_metadata.query is not None else query, + ) + observer = self.agent.observer + total_output_tokens = 0 + final_answer_for_trace = None + with monitoring_manager.start_agent_run(metadata): + with monitoring_manager.trace_agent_step( + "agent.run.loop", + metadata, + step_type="agent_loop", + ): + runtime_state_snapshots = [] + try: + query = self._prepare_file_workspace(query) + runtime_state_snapshots = self._set_runtime_metadata_for_agent_tree( + self.agent, + (additional_args or {}).get("metadata", {}), + ) + step_log = None + run_kwargs = {"stream": True, "reset": reset} + if additional_args is not None: + run_kwargs["additional_args"] = additional_args + for step_log in self.agent.run(query, **run_kwargs): + # Add content to observer + if not isinstance(step_log, ActionStep): + continue + + # Real tool-call chunks are emitted by CoreAgent + # (_emit_real_tool_chunks_from_code) right after the + # PARSE chunk, so we deliberately skip re-emitting them + # here to avoid duplicating the synthetic + # ``python_interpreter`` ToolCall that smolagents + # stamps on every action step. + + # Emit token stats after each action step + step_duration = getattr(step_log.timing, "duration", None) + step_input = None + step_output = None + if hasattr(step_log, "token_usage") and step_log.token_usage is not None: + step_input = getattr(step_log.token_usage, "input_tokens", None) + step_output = getattr(step_log.token_usage, "output_tokens", None) + if step_output: + total_output_tokens += step_output + + estimated_context = None + last_metric = None + if hasattr(self.agent, "step_metrics") and self.agent.step_metrics: + last_metric = self.agent.step_metrics[-1] + estimated_context = last_metric.get( + "memory_state", {} + ).get("estimated_input_tokens") + + token_threshold = None + context_window_tokens = None + effective_input_limit_tokens = None + context_processing_mode = None + context_runtime = getattr(self.agent, "context_runtime", None) + if context_runtime is not None: + token_threshold = context_runtime.token_threshold + context_window_tokens = context_runtime.context_window_tokens + effective_input_limit_tokens = context_runtime.effective_input_limit_tokens + context_processing_mode = context_runtime.processing_mode + + token_data = { + "step_number": step_log.step_number, + "duration": round(float(step_duration), 2) if step_duration is not None else 0.0, + "step_input_tokens": step_input, + "step_output_tokens": step_output, + "total_output_tokens": total_output_tokens, + "estimated_context_tokens": estimated_context, + "token_threshold": token_threshold, + "context_window_tokens": context_window_tokens, + "effective_input_limit_tokens": effective_input_limit_tokens, + "context_processing_mode": context_processing_mode, + "output_finish_reason": getattr( + getattr(self.agent, "model", None), + "last_finish_reason", + None, + ), + } + if last_metric: + compression = last_metric.get("compression", {}) or {} + token_data.update({ + "compression_calls": compression.get("calls", 0), + "compression_input_tokens": compression.get("input_tokens", 0), + "compression_output_tokens": compression.get("output_tokens", 0), + "compression_cache_hits": compression.get("cache_hits", 0), + "compression_cache_types": compression.get("cache_types", []), + "compression_ratio": last_metric.get("compression_ratio", 0.0), + "uncompressed_est_tokens": last_metric.get("uncompressed_mem_est_input", 0), + }) + active_model = getattr(self.agent, "model", None) + cache_usage = getattr(active_model, "last_prompt_cache_usage", None) + cache_advice = getattr(active_model, "last_provider_cache_advice", None) + if cache_usage is not None: + metrics_source = getattr(cache_usage, "metrics_source", "capability_unknown") + metrics_available = metrics_source not in {"none", "capability_unknown"} + capability_supported = bool(getattr(cache_advice, "supported", False)) + token_data.update({ + "provider_cache_status": ( + "available" if metrics_available else + "unavailable" if capability_supported else + "unsupported" + ), + "provider_cache_metrics_source": metrics_source, + "provider_cache_hit": bool(getattr(cache_usage, "provider_cache_hit", False)), + "provider_cached_input_tokens": int( + getattr(cache_usage, "cached_input_tokens", 0) or 0 + ), + "provider_uncached_input_tokens": int( + getattr(cache_usage, "uncached_input_tokens", 0) or 0 + ), + }) + observer.add_message("", ProcessType.TOKEN_COUNT, json.dumps(token_data)) + + if ( + hasattr(step_log, "error") + and step_log.error is not None + and not getattr(step_log, "_suppress_user_error", False) + ): + # Action-step failures are observations in the ReAct loop: + # the model receives them and can repair/retry on the next + # step. Surface them as warnings so the UI does not imply + # that the whole run has already failed. + observer.add_message("", ProcessType.WARNING, str(step_log.error)) + + if step_log is None: + raise ValueError("Agent run produced no output") + + final_answer = step_log.output # Last log is the run's final_answer + + if isinstance(final_answer, AgentText): + final_answer_str = convert_code_format(final_answer.to_string()) + else: + # prepare for multi-modal final_answer + final_answer_str = convert_code_format(str(final_answer)) + final_answer_str = re.sub( + THINK_TAG_PATTERN, "", final_answer_str, flags=re.DOTALL | re.IGNORECASE) + # Remove thinking prefix content (until two newlines) + final_answer_str = re.sub( + THINK_PREFIX_PATTERN, "", final_answer_str, flags=re.DOTALL) + final_answer_str = _ensure_non_empty_final_answer( + final_answer_str, + getattr(observer, "lang", "en"), + ) + final_answer_for_trace = final_answer_str + monitoring_manager.set_openinference_output(final_answer_str) + observer.complete_final_answer(self.agent.agent_name, final_answer_str) + + # Check if we need to stop from external stop_event + if self.agent.stop_event.is_set(): + observer.add_message(self.agent.agent_name, ProcessType.WARNING, + "Agent execution interrupted by external stop signal") + except ModelOutputProtocolExhaustedError as e: + observer.add_message( + agent_name=self.agent.agent_name, + process_type=ProcessType.ERROR, + content=str(e), + error_code="model_output_protocol_exhausted", + retryable=False, + ) + raise + except ModelInvocationTerminalError as e: + observer.add_message( + agent_name=self.agent.agent_name, + process_type=ProcessType.ERROR, + content=e.safe_message(getattr(observer, "lang", "en")), + error_code=e.error_code.value, + retryable=False, + ) + raise + except Exception as e: + if is_mcp_timeout_error(e): + raise + observer.add_message(agent_name=self.agent.agent_name, process_type=ProcessType.ERROR, + content=f"Error in interaction: {str(e)}") + raise ValueError(f"Error in interaction: {str(e)}") + + finally: + self._close_subagent_invocations() + self._restore_runtime_metadata_for_agent_tree(runtime_state_snapshots) + self._log_step_metrics() + self.close() + + if final_answer_for_trace is not None: + if hasattr(self.agent, "step_metrics"): + monitoring_manager.set_agent_context_metrics(self.agent.step_metrics) + monitoring_manager.set_openinference_output(final_answer_for_trace) + return final_answer_for_trace + + + def set_agent(self, agent: CoreAgent): + if not isinstance(agent, CoreAgent): + raise TypeError(f"agent must be a CoreAgent object, not {type(agent)}") + self.agent = agent + + + def _log_step_metrics(self): + """Output step_metrics to log or local file for quantitative analysis of context management.""" + if not hasattr(self.agent, "step_metrics") or not self.agent.step_metrics: + return + + metrics = self.agent.step_metrics + + # Pre-collect all values + real_i_vals = [m['main_llm']['input_tokens'] for m in metrics] + real_o_vals = [m['main_llm']['output_tokens'] for m in metrics] + comp_i_vals = [m['compression']['input_tokens'] for m in metrics] + comp_o_vals = [m['compression']['output_tokens'] for m in metrics] + est_i_vals = [m['memory_state']['estimated_input_tokens'] for m in metrics] + est_o_vals = [m['memory_state']['estimated_output_tokens'] for m in metrics] + raw_i_vals = [m['uncompressed_mem_est_input'] for m in metrics] + save_vals = [f"{m['compression_ratio']}%" for m in metrics] + hit_vals = [str(m['cache_hit']) for m in metrics] + + # Total summary + total_ri = sum(real_i_vals) + total_ro = sum(real_o_vals) + total_ci = sum(comp_i_vals) + total_co = sum(comp_o_vals) + total_ei = sum(est_i_vals) + total_eo = sum(est_o_vals) + total_raw = sum(raw_i_vals) + hit_count = sum(1 for m in metrics if m['cache_hit']) + + if total_raw > 0: + total_save_str = f"{round((1 - total_ei / total_raw) * 100, 1)}%" + else: + total_save_str = "N/A" + hit_total_str = f"{hit_count}/{len(metrics)}" + + # Column widths based on max value width + def _val_width(vals, extra_val=None): + w = 0 + for v in vals: + w = max(w, len(str(v))) + if extra_val is not None: + w = max(w, len(str(extra_val))) + return w + + w_ri = _val_width(real_i_vals, total_ri) + w_ro = _val_width(real_o_vals, total_ro) + w_ci = _val_width(comp_i_vals, total_ci) + w_co = _val_width(comp_o_vals, total_co) + w_ei = _val_width(est_i_vals, total_ei) + w_eo = _val_width(est_o_vals, total_eo) + w_raw = _val_width(raw_i_vals, total_raw) + w_save = _val_width(save_vals, total_save_str) + w_hit = _val_width(hit_vals, hit_total_str) + + # Prefix formatting + max_step_digits = max(len(str(m['step_number'])) for m in metrics) + step_prefix_fmt = f"Step {{:>{max_step_digits}}}: " + total_prefix = "Total: " + " " * max_step_digits + + lines = [] + for i, m in enumerate(metrics): + lines.append( + step_prefix_fmt.format(m['step_number']) + + f"real_i={real_i_vals[i]:>{w_ri}} real_o={real_o_vals[i]:>{w_ro}} | " + f"comp_i={comp_i_vals[i]:>{w_ci}} comp_o={comp_o_vals[i]:>{w_co}} | " + f"est_i={est_i_vals[i]:>{w_ei}} est_o={est_o_vals[i]:>{w_eo}} | " + f"est_raw_i={raw_i_vals[i]:>{w_raw}} save={save_vals[i]:>{w_save}} | " + f"hit={hit_vals[i]:>{w_hit}}" + ) + + lines.append( + total_prefix + + f"real_i={total_ri:>{w_ri}} real_o={total_ro:>{w_ro}} | " + f"comp_i={total_ci:>{w_ci}} comp_o={total_co:>{w_co}} | " + f"est_i={total_ei:>{w_ei}} est_o={total_eo:>{w_eo}} | " + f"est_raw_i={total_raw:>{w_raw}} save={total_save_str:>{w_save}} | " + f"hit={hit_total_str:>{w_hit}}" + ) + context_runtime = getattr(self.agent, "context_runtime", None) + if context_runtime is not None: + lines.append(f"Context Manager Global: {context_runtime.global_compression_stats()}") + + lines.append( + "-----" + ) + logger.debug("\n".join(lines)) + + # Optional: write to local file + with open("nexent_context_metrics.log", "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + def _resolve_tool_class(self, name): + return globals().get(name) + + @contextmanager + def _legacy_subagent_invocation(self, config, definition, tree_context, scope, mark_drained): + """Preserve direct SDK child construction when no host run was supplied.""" + from nexent.core.agents.contracts.config import copy_invocation_config + from nexent.core.concurrency.cancellation import RunTerminated + runtime = type(self)( + observer=self.observer, model_config_list=self.model_config_list, stop_event=scope.stop_event, + mcp_tool_collection=self.mcp_tool_collection, redis_client=self.redis_client, + sandbox_config=self.sandbox_config, minio_client=self.minio_client, + conversation_id=self.conversation_id, user_id=self.user_id, tenant_id=self.tenant_id, + workspace_path=self.workspace_path, workspace_run_id=self.workspace_run_id, + cancellation_scope=scope, user_context=self.user_context, + tool_fastpath_enabled=self.tool_fastpath_enabled, + tool_fastpath_allowed_tools=self.tool_fastpath_allowed_tools, + ) + runtime._sandbox_skill_cleanup_owner = self._sandbox_skill_cleanup_owner + try: + agent = runtime.create_single_agent(copy_invocation_config(config), _managed_context=True, + _sandbox_tree_context=tree_context) + runtime.set_agent(agent) + runtime._set_runtime_metadata_for_agent_tree(agent, definition.get_runtime_metadata()) + runtime._initialize_sandbox_workspaces() + if scope.cancelled: + raise RunTerminated("Subagent invocation cancelled during allocation") + yield agent + finally: + runtime._close_subagent_invocations() + try: + if runtime.workspace_path: + runtime._pull_file_workspace_from_sandbox() + finally: + runtime._cleanup_sandbox() + mark_drained() + + @property + def core_run_info(self): + return self._core_run_info + + @core_run_info.setter + def core_run_info(self, info): + self._core_run_info = info + if info is not None: + from nexent.core.agents.orchestration.children import child_invocation_factory + self._child_invocation_factory = child_invocation_factory(info) + + +__all__ = [ + "NexentAgent", "CoreAgent", "Tool", "ActionStep", "TaskStep", "_wrap_tool_with_monitoring", + "_tool_name", "_is_retriever_tool", "_build_tool_input", "cleanup_run_workspace", + "SAFE_PYTHON_INTERPRETER_IMPORTS", "get_local_python_authorized_imports", +] diff --git a/sdk/nexent/core/agents/cores/smolagents/context.py b/sdk/nexent/core/agents/cores/smolagents/context.py new file mode 100644 index 0000000000..d9033839ec --- /dev/null +++ b/sdk/nexent/core/agents/cores/smolagents/context.py @@ -0,0 +1,378 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Managed context path thin adapter. + +All context policy and final payload assembly belongs to ContextManager. This +runtime only adapts CoreAgent lifecycle calls to the ContextManager API. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Sequence +from copy import deepcopy +from typing import Any, Mapping + +from smolagents.memory import ActionStep, AgentMemory, TaskStep +from smolagents.models import ChatMessage, MessageRole, Model + +from nexent.core.agents.context.budget import extract_message_text +from nexent.core.agents.context.evidence import ContextEvidenceCollector +from nexent.core.agents.context.manager import ContextManager +from nexent.core.agents.context.models import ContextItem, ContextItemInput, ContextItemType +from nexent.core.agents.context.run_context import ManagedRunContext +from nexent.core.agents.contracts.context import ContextEvidence, ContextMessage, ContextToolView, FinalContext +from nexent.core.agents.cores.smolagents.context_contracts import ( + ContextRuntime as ContextRuntime, +) +from nexent.core.agents.cores.smolagents.context_contracts import ( + ModelTool, +) +from nexent.core.agents.cores.smolagents.context_contracts import ( + UnconfiguredContextRuntime as UnconfiguredContextRuntime, +) + + +ModelMessage = ChatMessage | dict[str, object] +_UNCONFIGURED_RUNTIME_ERROR = "CoreAgent requires a context runtime from the agent factory" + + +ContextItemCandidate = ContextItemInput | ContextItem + + +class ManagedContextRuntime: + """Adapter for the ContextManager-owned managed path.""" + + def __init__( + self, + context_manager: ContextManager, + items: Sequence[ContextItemCandidate] | None = None, + ) -> None: + self._context_manager = context_manager + self.items = list(items or ()) + self._run_context: ManagedRunContext | None = None + self._evidence = ContextEvidenceCollector() + self._memory_adapter = SmolagentsMemoryAdapter(context_manager) + + @property + def context_manager(self) -> ContextManager: + """Return the manager permanently bound to this runtime.""" + return self._context_manager + + def replace_items(self, items: Sequence[ContextItemCandidate] | None) -> None: + """Replace this runtime's run-local fine-grained item snapshot.""" + self.items = list(items or ()) + self._run_context = None + + def prepare_run(self, *, memory: AgentMemory, fallback_system_prompt: str) -> None: + self._evidence.reset() + self._run_context = self.context_manager.prepare_items_context( + fallback_system_prompt=fallback_system_prompt, + items=self.items, + ) + + def _ensure_run_context(self, memory: AgentMemory) -> ManagedRunContext: + if self._run_context is None: + self._run_context = self.context_manager.prepare_items_context( + fallback_system_prompt="", + items=self.items, + ) + return self._run_context + + def prepare_step( + self, + *, + model: Model, + memory: AgentMemory, + current_run_start_idx: int, + tools: Sequence[ModelTool] | None = None, + request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), + request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), + ) -> FinalContext: + final_context = self.context_manager.assemble_context_items( + model=adapt_context_model(model), + current_items=self._memory_adapter._project_current_run(memory, current_run_start_idx), + tools=context_tool_views(tools), + purpose="step", + run_context=self._ensure_run_context(memory), + request_system_messages=context_messages(request_system_messages), + request_tail_messages=context_messages(request_tail_messages), + ) + self._evidence.record_call(final_context.evidence) + return final_context + + def recover_step( + self, + *, + model: Model, + memory: AgentMemory, + current_run_start_idx: int, + tools: Sequence[ModelTool] | None = None, + request_system_messages: Sequence[ChatMessage | dict[str, object]] = (), + request_tail_messages: Sequence[ChatMessage | dict[str, object]] = (), + ) -> FinalContext: + """Force a source-backed rebuild after explicit Provider overflow.""" + final_context = self.context_manager.assemble_context_items( + model=adapt_context_model(model), + current_items=self._memory_adapter._project_current_run(memory, current_run_start_idx), + tools=context_tool_views(tools), + purpose="step", + run_context=self._ensure_run_context(memory), + force_compaction=True, + request_system_messages=context_messages(request_system_messages), + request_tail_messages=context_messages(request_tail_messages), + ) + self._evidence.record_call(final_context.evidence) + return final_context + + def prepare_final_answer( + self, + *, + model: Model, + memory: AgentMemory, + current_run_start_idx: int, + task: str, + final_answer_templates: Mapping[str, Mapping[str, str]], + tools: Sequence[ModelTool] | None = None, + ) -> FinalContext: + final_context = self.context_manager.assemble_context_items( + model=adapt_context_model(model), + current_items=self._memory_adapter._project_current_run(memory, current_run_start_idx), + tools=context_tool_views(tools), + purpose="final_answer", + task=task, + final_answer_templates=final_answer_templates, + run_context=self._ensure_run_context(memory), + ) + self._evidence.record_call(final_context.evidence) + return final_context + + def recover_final_answer( + self, + *, + model: Model, + memory: AgentMemory, + current_run_start_idx: int, + task: str, + final_answer_templates: Mapping[str, Mapping[str, str]], + tools: Sequence[ModelTool] | None = None, + ) -> FinalContext: + """Force a final-answer rebuild after explicit Provider overflow.""" + final_context = self.context_manager.assemble_context_items( + model=adapt_context_model(model), + current_items=self._memory_adapter._project_current_run(memory, current_run_start_idx), + tools=context_tool_views(tools), + purpose="final_answer", + task=task, + final_answer_templates=final_answer_templates, + run_context=self._ensure_run_context(memory), + force_compaction=True, + ) + self._evidence.record_call(final_context.evidence) + return final_context + + def render_summary_messages(self, *, memory: AgentMemory) -> list[ChatMessage | dict[str, object]]: + """Return display-only memory messages without compression side effects.""" + return self._memory_adapter.render_memory_messages(memory) + + def compression_stats(self) -> dict[str, object]: + return self.context_manager.get_step_compression_stats() + + def global_compression_stats(self) -> dict[str, object]: + return self.context_manager.get_all_compression_stats() + + def token_counts(self) -> dict[str, int | None]: + return self.context_manager.get_token_counts() + + def consume_history_summary_event(self) -> dict[str, object] | None: + return self.context_manager.consume_history_summary_event() + + def finalize_evidence(self, *, status: str) -> ContextEvidence: + return self._evidence.finalize(status=status) + + @property + def chars_per_token(self) -> float: + return self.context_manager.config.chars_per_token + + @property + def token_threshold(self) -> int | None: + return self.context_manager.config.token_threshold + + @property + def context_window_tokens(self) -> int | None: + return self.context_manager.config.context_window_tokens + + @property + def effective_input_limit_tokens(self) -> int | None: + return self.context_manager.effective_input_limit_tokens + + @property + def processing_mode(self) -> str | None: + return self.context_manager.processing_mode + + +class SmolagentsMemoryAdapter: + """Project native memory while retaining the CM-owned representation cache.""" + + def __init__(self, manager): + self._current_item_cache = manager._current_item_cache + self._to_json_value = manager._to_json_value + self.build_context_messages = manager.build_context_messages + + def _neutral_action_content(self, step: ActionStep, action_index: int) -> dict[str, Any]: + """Preserve completed-action evidence without upstream protocol labels.""" + return { + "step_number": getattr(step, "step_number", action_index + 1), + "tool_calls": self._to_json_value(getattr(step, "tool_calls", None)), + "observations": self._to_json_value(getattr(step, "observations", None)), + "error": str(getattr(step, "error", "")) if getattr(step, "error", None) else None, + "result": self._to_json_value(getattr(step, "action_output", None)), + } + + def _project_current_run(self, memory: AgentMemory, start: int) -> list[ContextItem]: + projected: list[ContextItem] = [] + action_index = planning_index = 0 + for index, step in enumerate(memory.steps[start:]): + cached = self._current_item_cache.get(id(step)) + if cached is not None: + projected.append(cached) + if cached.type == ContextItemType.CURRENT_ACTION: + action_index += 1 + elif cached.type == ContextItemType.CURRENT_PLANNING: + planning_index += 1 + continue + if isinstance(step, TaskStep): + item = ContextItem.from_input( + ContextItemInput( + id=f"current_task:{index}", + type=ContextItemType.CURRENT_TASK, + content={"text": step.task or ""}, + metadata={"layout_order": index, "run_order": index}, + ) + ) + projected.append(item) + elif isinstance(step, ActionStep): + content = self._neutral_action_content(step, action_index) + # Render completed actions from structured fields instead of + # smolagents ActionStep.to_messages(). The upstream rendering + # injects protocol labels such as "Calling tools:" and + # "Observation:" into the next model request, which can prime + # reasoning models to emit a configured stop sequence. The + # neutral representation preserves the executable action, + # outcome, error, result, and ordering without changing how + # the live ReAct step is parsed or executed. + item = ContextItem.from_input( + ContextItemInput( + id=f"current_action:{action_index}", + type=ContextItemType.CURRENT_ACTION, + content=content, + metadata={"layout_order": action_index, "run_order": index}, + ) + ) + projected.append(item) + action_index += 1 + elif step.__class__.__name__ == "PlanningStep": + item = ContextItem.from_input( + ContextItemInput( + id=f"current_planning:{planning_index}", + type=ContextItemType.CURRENT_PLANNING, + content={"text": "\n".join(extract_message_text(m) for m in step.to_messages())}, + metadata={"layout_order": planning_index, "run_order": index}, + ) + ) + projected.append(item) + planning_index += 1 + else: + continue + self._current_item_cache[id(step)] = item + return projected + + def render_memory_messages(self, memory): + messages = [] + if memory.system_prompt: + messages.extend(memory.system_prompt.to_messages()) + action_index = 0 + for step in memory.steps: + if isinstance(step, ActionStep): + item = ContextItem.from_input( + ContextItemInput( + id=f"summary_action:{action_index}", + type=ContextItemType.CURRENT_ACTION, + content=self._neutral_action_content(step, action_index), + metadata={"layout_order": action_index}, + ) + ) + messages.extend(self.build_context_messages([item])) + action_index += 1 + else: + messages.extend(step.to_messages()) + return messages + + +class SmolagentsContextModel: + """Convert neutral auxiliary messages without changing the selected model state.""" + + def __init__(self, model): + object.__setattr__(self, "_model", model) + + def __getattr__(self, name): + return getattr(self._model, name) + + def __setattr__(self, name, value): + setattr(self._model, name, value) + + @property + def model_id(self): + """Keep CM audit/cache identity for custom models without an explicit ID.""" + return ( + getattr(self._model, "model_id", None) + or getattr(self._model, "model_name", None) + or self._model.__class__.__name__ + ) + + @property + def context_call_parameters(self): + return inspect.signature(self._model.__call__).parameters + + def __call__(self, messages, **kwargs): + converted = [ + message + if isinstance(message, (ChatMessage, dict)) + else ChatMessage(role=MessageRole(message.role), content=deepcopy(message.content)) + for message in messages + ] + return self._model(converted, **kwargs) + + +def adapt_context_model(model): + """Reuse a port when a legacy entry already adapted this model.""" + return model if isinstance(model, SmolagentsContextModel) else SmolagentsContextModel(model) + + +def context_messages(messages): + """Detach native request-only messages while keeping the existing CM audit rule.""" + return [ + ContextMessage( + role=str(getattr(message.role, "value", message.role)), + content=deepcopy(message.content), + fingerprint_payload=ContextManager._normalize(message), + ) + if isinstance(message, ChatMessage) + else deepcopy(message) + for message in messages + ] + + +def context_tool_views(tools): + """Remove executable objects without changing legacy budget or fingerprint inputs.""" + if tools is None: + return None + return [ + deepcopy(tool) + if isinstance(tool, dict) + else ContextToolView( + fingerprint_payload=ContextManager._normalize(tool), + budget_text=str(tool), + ) + for tool in tools + ] diff --git a/sdk/nexent/core/context_runtime/contracts.py b/sdk/nexent/core/agents/cores/smolagents/context_contracts.py similarity index 68% rename from sdk/nexent/core/context_runtime/contracts.py rename to sdk/nexent/core/agents/cores/smolagents/context_contracts.py index e21e1a5c36..facda64084 100644 --- a/sdk/nexent/core/context_runtime/contracts.py +++ b/sdk/nexent/core/agents/cores/smolagents/context_contracts.py @@ -1,87 +1,45 @@ -"""Contracts for the single ContextManager-backed runtime path.""" +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""smolagents lifecycle interfaces without importing ContextManager implementations.""" + from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import replace from typing import TYPE_CHECKING, Protocol +from smolagents.memory import AgentMemory +from smolagents.models import ChatMessage, MessageRole, Model +from smolagents.tools import Tool + +from nexent.core.agents.context.models import ContextItem +from nexent.core.agents.contracts.context import ContextEvidence, ContextItemInput, ContextMessage, FinalContext + if TYPE_CHECKING: - from smolagents.memory import AgentMemory - from smolagents.models import ChatMessage, Model - from smolagents.tools import Tool + from nexent.core.agents.context.manager import ContextManager - from ..agents.context.manager import ContextManager - from ..agents.context.models import ContextItem, ContextItemInput +ContextItemCandidate = ContextItemInput | ContextItem +ModelMessage = ChatMessage | dict[str, object] +ModelTool = Tool | dict[str, object] +_UNCONFIGURED_RUNTIME_ERROR = "CoreAgent requires a context runtime from the agent factory" - ContextItemCandidate = ContextItemInput | ContextItem - ModelMessage = ChatMessage | dict[str, object] - ModelTool = Tool | dict[str, object] -else: - AgentMemory = object - ContextItemCandidate = object - ContextManager = object - Model = object - ModelMessage = object - ModelTool = object +def native_context_messages(messages): + """Restore the native model input shape only at the Core's send boundary.""" + return [ + ChatMessage(role=MessageRole(message.role), content=message.content) + if isinstance(message, ContextMessage) + else message + for message in messages + ] -_UNCONFIGURED_RUNTIME_ERROR = "CoreAgent requires a context runtime from the agent factory" -@dataclass(frozen=True) -class ContextEvidence: - purpose: str = "step" - selected_item_ids: tuple[str, ...] = () - selected_item_types: tuple[str, ...] = () - stable_message_count: int = 0 - dynamic_message_count: int = 0 - compression_records: tuple[object, ...] = () - stable_prefix_fingerprint: str | None = None - prefix_change_reasons: tuple[str, ...] = () - policy_fingerprint: str | None = None - processing_mode: str = "passthrough" - effective_input_limit_tokens: int = 0 - compaction_trigger_threshold_tokens: int = 0 - compaction_target_tokens: int = 0 - compaction_attempts: int = 0 - compressible_history_tokens: int = 0 - non_history_tokens: int = 0 - compaction_stop_reason: str = "not_triggered" - raw_token_estimate: int = 0 - final_token_estimate: int = 0 - loaded_summary_unit_id: int | None = None - loaded_summary_coverage: int | None = None - new_history_turn_count: int = 0 - history_compression_triggered: bool = False - new_summary_coverage: int | None = None - summary_persist_status: str = "not_attempted" - item_representations: tuple[tuple[str, str], ...] = () - current_action_compact_count: int = 0 - representation_cache_hits: int = 0 - representation_cache_misses: int = 0 - compaction_attempts_exhausted: bool = False - exceeds_effective_input_limit: bool = False - model_call_count: int = 0 - loop_status: str | None = None - messages_fingerprint: str | None = None - tools_fingerprint: str | None = None - system_messages_fingerprint: str | None = None - history_messages_fingerprint: str | None = None - final_answer_prompt_fingerprint: str | None = None - message_roles: tuple[str, ...] = () - history_message_roles: tuple[str, ...] = () - compression_attempted: bool = False - fallback_compaction_used: bool = False - - -@dataclass(frozen=True) -class FinalContext: - """The only context payload permitted to enter a model call.""" - - messages: list[ModelMessage] - tools: list[dict[str, object]] = field(default_factory=list) - evidence: ContextEvidence = field(default_factory=ContextEvidence) - memory_messages: list[ModelMessage] | None = None +def native_context_rebuild(context): + """Keep legacy callback identity unless neutral messages need native conversion.""" + if not any(isinstance(message, ContextMessage) for message in context.messages): + return context + return replace(context, messages=native_context_messages(context.messages)) class ContextRuntime(Protocol): diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/cores/smolagents/core_agent.py similarity index 97% rename from sdk/nexent/core/agents/core_agent.py rename to sdk/nexent/core/agents/cores/smolagents/core_agent.py index 39bb150dab..12dad124ce 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/cores/smolagents/core_agent.py @@ -5,57 +5,45 @@ import logging import os import re +import threading import time import uuid -import threading +from collections.abc import Generator from copy import deepcopy from datetime import datetime from textwrap import dedent -from typing import Any, Optional, List, Dict -from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Dict, List, Optional +from jinja2 import StrictUndefined, Template from rich.console import Group from rich.text import Text - -from smolagents.agents import ( - ActionOutput, - AgentError, - CodeAgent, - RunResult, - handle_agent_output_types, -) -from smolagents.memory import ActionStep, PlanningStep, FinalAnswerStep, ToolCall, TaskStep, SystemPromptStep -from smolagents.models import ChatMessage, CODEAGENT_RESPONSE_FORMAT, MessageRole -from smolagents.monitoring import LogLevel, Timing, YELLOW_HEX, TokenUsage -from smolagents.utils import AgentExecutionError, AgentGenerationError, truncate_content, AgentMaxStepsError, \ - extract_code_from_text - -from ...monitor import get_monitoring_manager - -from ..model_errors import ModelErrorCode, ModelInvocationTerminalError -from ..utils.observer import MessageObserver, ProcessType -from .prompt.user_context import has_current_time_marker, render_user_context -from .prompt import AgentPromptComposer -from ..utils.model_output_diagnostics import ( - bounded_rejected_output_preview, - rejected_output_preview_enabled, +from smolagents.agents import ActionOutput, AgentError, CodeAgent, RunResult, handle_agent_output_types +from smolagents.memory import ActionStep, FinalAnswerStep, PlanningStep, SystemPromptStep, TaskStep, ToolCall +from smolagents.models import CODEAGENT_RESPONSE_FORMAT, ChatMessage, MessageRole +from smolagents.monitoring import YELLOW_HEX, LogLevel, Timing, TokenUsage +from smolagents.utils import ( + AgentExecutionError, + AgentGenerationError, + AgentMaxStepsError, + extract_code_from_text, + truncate_content, ) -from jinja2 import Template, StrictUndefined -from typing import TYPE_CHECKING -if TYPE_CHECKING: - import PIL.Image - -from .agent_model import AgentVerificationConfig -from ...consts.mcp_errors import is_mcp_timeout_error, propagate_mcp_timeout -from ..context_runtime.contracts import ContextRuntime, UnconfiguredContextRuntime -from .verification import ( - VerificationController, - VerificationResult, - render_guardrail_refusal, - render_tool_input_refusal, +from nexent.consts.mcp_errors import is_mcp_timeout_error, propagate_mcp_timeout +from nexent.core.agents.context.budget import message_role +from nexent.core.agents.contracts.clarification import ( + ClarificationForm, + choose_clarification_tool_name, + render_question_text, +) +from nexent.core.agents.contracts.config import AgentVerificationConfig +from nexent.core.agents.cores.smolagents.context_contracts import ( + ContextRuntime, + UnconfiguredContextRuntime, + native_context_messages, + native_context_rebuild, ) -from .output_protocol import ( +from nexent.core.agents.cores.smolagents.output_protocol import ( ExecutableAction, ExplicitFinalAnswer, ModelOutputProtocolError, @@ -64,16 +52,33 @@ ProtocolErrorReason, RuntimeFinalAnswer, classify_model_output, + extract_clarification_form, has_meaningful_visible_content, is_raw_final_answer_candidate, unicode_category_summary, ) -from .context.budget import message_role -from ..utils.token_estimation import msg_token_count -from .plan_repo import PlanRepo -from ..concurrency.cancellation import RunTerminated -from .clarification import ClarificationForm, choose_clarification_tool_name, clarification_policy, render_question_text -from .output_protocol import extract_clarification_form +from nexent.core.agents.cores.smolagents.plan_repo import PlanRepo +from nexent.core.agents.cores.smolagents.verification import ( + VerificationController, + VerificationResult, + render_guardrail_refusal, + render_tool_input_refusal, +) +from nexent.core.agents.prompt import AgentPromptComposer +from nexent.core.agents.prompt.clarification import clarification_policy +from nexent.core.agents.prompt.user_context import has_current_time_marker, render_user_context +from nexent.core.concurrency.cancellation import RunTerminated +from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError +from nexent.core.utils.model_output_diagnostics import bounded_rejected_output_preview, rejected_output_preview_enabled +from nexent.core.utils.observer import MessageObserver, ProcessType +from nexent.core.utils.token_estimation import msg_token_count +from nexent.monitor import get_monitoring_manager + + +if TYPE_CHECKING: + import PIL.Image + + from nexent.core.agents.contracts.config import AgentPlan # Model-call scoped logger routed to nexent_model_call.log by the runtime @@ -1273,7 +1278,7 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: ) get_monitoring_manager().record_final_context_evidence(final_context.evidence, step_number=self.step_number) self._emit_history_summary_event() - input_messages = final_context.messages + input_messages = native_context_messages(final_context.messages) chars_per_token = self.context_runtime.chars_per_token # Baseline for the per-step compression ratio. ``final_context.messages`` # is already the compressed payload, so use the ContextManager's raw @@ -1359,7 +1364,7 @@ def rebuild_after_provider_overflow(): if rebuilt.memory_messages is not None else rebuilt.messages ) self._emit_history_summary_event() - return rebuilt + return native_context_rebuild(rebuilt) chat_message: ChatMessage = self.model( input_messages, @@ -1628,7 +1633,7 @@ def rebuild_after_provider_overflow(): self.name, {"code": code_action, "step_number": memory_step.step_number}, ): - from .sandbox import _execute_with_tool_context + from nexent.core.agents.sandbox.execution import _execute_with_tool_context code_output = _execute_with_tool_context(self.python_executor, code_action) monitoring_manager.set_tool_output({ @@ -2345,7 +2350,7 @@ def _handle_max_steps_reached(self, task: str) -> Any: ) get_monitoring_manager().record_final_context_evidence(final_context.evidence, step_number=self.step_number) self._emit_history_summary_event() - messages = final_context.messages + messages = native_context_messages(final_context.messages) # Create the final memory step with error final_memory_step = ActionStep( @@ -2378,7 +2383,7 @@ def rebuild_final_after_provider_overflow(): rebuilt.evidence, step_number=self.step_number ) self._emit_history_summary_event() - return rebuilt + return native_context_rebuild(rebuilt) model_kwargs: dict[str, Any] = {} if getattr(self.model, "supports_deferred_attempt_commit", False) is True: diff --git a/sdk/nexent/core/agents/output_protocol.py b/sdk/nexent/core/agents/cores/smolagents/output_protocol.py similarity index 98% rename from sdk/nexent/core/agents/output_protocol.py rename to sdk/nexent/core/agents/cores/smolagents/output_protocol.py index 9cd5c85bad..0c23b87911 100644 --- a/sdk/nexent/core/agents/output_protocol.py +++ b/sdk/nexent/core/agents/cores/smolagents/output_protocol.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Closed output protocol for Nexent CodeAgent runtimes.""" from __future__ import annotations @@ -13,8 +15,9 @@ from pydantic import ValidationError -from .clarification import CLARIFICATION_SCHEMA_GUIDANCE, ClarificationForm - +from nexent.core.agents.contracts.adapter import ModelOutputProtocolExhaustedError as ModelOutputProtocolExhaustedError +from nexent.core.agents.contracts.clarification import ClarificationForm +from nexent.core.agents.prompt.clarification import CLARIFICATION_SCHEMA_GUIDANCE OutputProtocol = Literal["code_action", "final_envelope"] @@ -84,10 +87,6 @@ def __init__(self, answer: Any, source: str) -> None: self.source = source -class ModelOutputProtocolExhaustedError(Exception): - """Terminal failure after the runtime exhausts protocol repair attempts.""" - - _RUN_RE = re.compile(r"\A```(?P[\s\S]*?)```\Z") _ACTION_PREAMBLE_RE = re.compile( r"\A(?P(?:(?:[Tt]hink|[Tt]hought|思考)[ \t]*[::][\s\S]*?\n)?" diff --git a/sdk/nexent/core/agents/plan_repo.py b/sdk/nexent/core/agents/cores/smolagents/plan_repo.py similarity index 97% rename from sdk/nexent/core/agents/plan_repo.py rename to sdk/nexent/core/agents/cores/smolagents/plan_repo.py index 6dcda8ef1c..e7b240a6bd 100644 --- a/sdk/nexent/core/agents/plan_repo.py +++ b/sdk/nexent/core/agents/cores/smolagents/plan_repo.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Plan persistence layer: Redis primary + local memory fallback. SDK internal use only. Redis client is passed in from the backend @@ -9,6 +11,7 @@ import threading from typing import Optional + logger = logging.getLogger(__name__) diff --git a/sdk/nexent/core/agents/cores/smolagents/runner.py b/sdk/nexent/core/agents/cores/smolagents/runner.py new file mode 100644 index 0000000000..dfd4b7a704 --- /dev/null +++ b/sdk/nexent/core/agents/cores/smolagents/runner.py @@ -0,0 +1,117 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import logging +from typing import Any + +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.orchestration.execution import ( + _get_authorized_context_items, + _get_authorized_history, + _get_default_agent_thread_manager, + build_run_additional_args, +) +from nexent.core.agents.resources.mcp import ( + ManagedMCPToolCollection, + _normalize_mcp_config, + _resolve_mcp_request_timeout_seconds, +) +from nexent.core.concurrency import RunCancellationScope +from nexent.core.utils.observer import ProcessType + + +logger = logging.getLogger("run_agent") + + +def _log_memory_value_assessment(agent: Any) -> None: + """Emit one content-free memory assessment summary for each agent run.""" + tools = getattr(agent, "tools", {}) or {} + store_tool = tools.get("store_memory") if hasattr(tools, "get") else None + if store_tool is None: + logger.info( + "event=memory_value_assessment decision=unavailable " + "invocation_count=0 successful_store_count=0 last_outcome=tool_unavailable" + ) + return + + invocation_count = int(getattr(store_tool, "invocation_count", 0) or 0) + successful_store_count = int(getattr(store_tool, "successful_store_count", 0) or 0) + decision = "store_attempted" if invocation_count else "skip" + logger.info( + "event=memory_value_assessment tenant_id=%s user_id=%s agent_id=%s " + "conversation_id=%s decision=%s invocation_count=%d " + "successful_store_count=%d last_outcome=%s", + getattr(store_tool, "tenant_id", ""), + getattr(store_tool, "user_id", ""), + getattr(store_tool, "agent_id", ""), + getattr(store_tool, "conversation_id", ""), + decision, + invocation_count, + successful_store_count, + getattr(store_tool, "last_outcome", "not_invoked"), + ) + + +def _run_with_resources(agent_run_info, tool_collection=None): + """Own resources from partial setup through execution and cleanup.""" + user_context = getattr(agent_run_info, "user_context", None) + kwargs = dict( + observer=agent_run_info.observer, + model_config_list=agent_run_info.model_config_list, + stop_event=agent_run_info.stop_event, + redis_client=agent_run_info.redis_client, + user_context=user_context, + sandbox_config=getattr(agent_run_info, "sandbox_config", None), + tool_fastpath_enabled=getattr(agent_run_info, "tool_fastpath_enabled", True), + tool_fastpath_allowed_tools=getattr(agent_run_info, "tool_fastpath_allowed_tools", None), + minio_client=getattr(agent_run_info, "minio_client", None), + conversation_id=agent_run_info.conversation_id, + user_id=agent_run_info.user_id, + tenant_id=getattr(agent_run_info, "tenant_id", None), + workspace_path=getattr(agent_run_info, "workspace_path", None), + workspace_run_id=getattr(agent_run_info, "workspace_run_id", None), + minio_files=getattr(agent_run_info, "minio_files", None), + cancellation_scope=agent_run_info.cancellation_scope, + ) + if tool_collection is not None: + kwargs["mcp_tool_collection"] = tool_collection + nexent = NexentAgent(**kwargs) + agent = None + try: + nexent.core_run_info = agent_run_info + agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. + agent_run_info.agent_config, + context_items_override=_get_authorized_context_items(agent_run_info), + ) + nexent.set_agent(agent) + + nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) + return nexent.agent_run_with_observer( + query=agent_run_info.query, + reset=False, + additional_args=build_run_additional_args(agent_run_info), + ) + finally: + try: + if agent is not None: + _log_memory_value_assessment(agent) + finally: + nexent.close() + + +def _run_smolagents(agent_run_info): + """Execute only the smolagents loop with shared managed resources.""" + mcp_host = agent_run_info.mcp_host + if not mcp_host: + return _run_with_resources(agent_run_info) + agent_run_info.observer.add_message("", ProcessType.AGENT_NEW_RUN, "") + from nexent.core.agents.resources.compatibility.smolagents_tools import mcp_adapter_factory + with ManagedMCPToolCollection( + manager=agent_run_info.thread_manager or _get_default_agent_thread_manager(), + server_parameters=[_normalize_mcp_config(item) for item in mcp_host], + cancellation_scope=agent_run_info.cancellation_scope or RunCancellationScope(agent_run_info.stop_event), + tool_timeout_seconds=agent_run_info.mcp_tool_timeout_seconds, + close_timeout_seconds=agent_run_info.mcp_close_timeout_seconds, + connect_timeout_seconds=_resolve_mcp_request_timeout_seconds(agent_run_info), + tool_adapter_factory=mcp_adapter_factory, + ) as tool_collection: + return _run_with_resources(agent_run_info, tool_collection) diff --git a/sdk/nexent/core/agents/tool_fastpath.py b/sdk/nexent/core/agents/cores/smolagents/tool_fastpath.py similarity index 98% rename from sdk/nexent/core/agents/tool_fastpath.py rename to sdk/nexent/core/agents/cores/smolagents/tool_fastpath.py index 313339d455..b76e48dbcd 100644 --- a/sdk/nexent/core/agents/tool_fastpath.py +++ b/sdk/nexent/core/agents/cores/smolagents/tool_fastpath.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Restricted official-tool actions with deferred ordinary executor creation.""" from __future__ import annotations @@ -276,7 +278,7 @@ def _active_imports(self) -> dict[str, str]: } def __call__(self, code: str) -> Any: - from .sandbox import _execute_with_tool_context + from nexent.core.agents.sandbox.execution import _execute_with_tool_context with self._lock: self._check_active() diff --git a/sdk/nexent/core/agents/verification.py b/sdk/nexent/core/agents/cores/smolagents/verification.py similarity index 99% rename from sdk/nexent/core/agents/verification.py rename to sdk/nexent/core/agents/cores/smolagents/verification.py index 60dad95c3c..6b9bc860b4 100644 --- a/sdk/nexent/core/agents/verification.py +++ b/sdk/nexent/core/agents/cores/smolagents/verification.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + from __future__ import annotations import ast @@ -9,10 +11,10 @@ from smolagents.models import ChatMessage, MessageRole from smolagents.utils import truncate_content -from ...monitor import get_monitoring_manager -from ..prompts import load_prompt -from ..utils.observer import MessageObserver, ProcessType -from .agent_model import AgentVerificationConfig, GuardrailConfig, GuardrailRule +from nexent.core.agents.contracts.config import AgentVerificationConfig, GuardrailConfig, GuardrailRule +from nexent.core.prompts import load_prompt +from nexent.core.utils.observer import MessageObserver, ProcessType +from nexent.monitor import get_monitoring_manager @dataclass diff --git a/sdk/nexent/core/agents/core_adapters/factory.py b/sdk/nexent/core/agents/factory.py similarity index 82% rename from sdk/nexent/core/agents/core_adapters/factory.py rename to sdk/nexent/core/agents/factory.py index feff1f12bb..053fd4fb85 100644 --- a/sdk/nexent/core/agents/core_adapters/factory.py +++ b/sdk/nexent/core/agents/factory.py @@ -1,9 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Registry and lazy construction of execution-core adapters.""" import re from threading import RLock -from .base import AgentCoreAdapter, UnsupportedAgentCoreConfig +from nexent.core.agents.contracts.adapter import AgentCoreAdapter, UnsupportedAgentCoreConfig class AgentCoreFactory: @@ -28,11 +30,11 @@ def register(cls, name: str, adapter: type[AgentCoreAdapter]) -> None: def create(cls, run_info, *, smolagents_executor=None) -> AgentCoreAdapter: name = getattr(run_info.agent_config, "agent_core", "smolagents") if name == "smolagents": - from .smolagents import SmolagentsAdapter + from nexent.core.agents.cores.smolagents.adapter import SmolagentsAdapter return SmolagentsAdapter(run_info, executor=smolagents_executor) elif name == "jiuwen": - from .jiuwen import JiuwenAdapter + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter adapter = JiuwenAdapter else: diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py deleted file mode 100644 index 772bca3622..0000000000 --- a/sdk/nexent/core/agents/nexent_agent.py +++ /dev/null @@ -1,2122 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -from __future__ import annotations - -import functools -import inspect -import io -import json -import logging -import os -import re -import shutil -import tarfile -import time -from concurrent.futures import CancelledError -from contextlib import contextmanager -from copy import copy, deepcopy -from dataclasses import replace -from pathlib import Path -from threading import Event, Lock, RLock -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence - -from smolagents import ActionStep, AgentText, TaskStep, Timing -from smolagents.tools import Tool - -from ...consts.mcp_errors import is_mcp_timeout_error -from ...monitor import AgentRunMetadata, get_agent_monitoring_context, get_monitoring_manager -from ..model_errors import ModelInvocationTerminalError -from ..models.openai_llm import OpenAIModel -from ..tools import * # Used for tool creation, do not delete!!! -from ..utils.constants import THINK_PREFIX_PATTERN, THINK_TAG_PATTERN -from ..utils.observer import MessageObserver, ProcessType -from .agent_model import AgentConfig, AgentHistory, ModelConfig, ToolConfig -from .clarification import choose_clarification_tool_name, clarification_policy -from .core_agent import CoreAgent, convert_code_format -from .output_protocol import ModelOutputProtocolExhaustedError -from .prompt.user_context import render_user_context -from .sandbox_workspace import SandboxWorkspace, probe_workspace -from .tool_parameters import apply_mcp_tool_parameters -from .tool_user_context import ( - apply_model_visible_tool_schemas_to_context_items, - apply_user_context_to_mcp_tool, -) - - -if TYPE_CHECKING: - from .context import ContextItemInput - from .subagent_wrapper import SubAgentToolWrapper - - -# Safe base imports for Python interpreter - excludes file modification and system access modules -SAFE_PYTHON_INTERPRETER_IMPORTS = [ - "math", "cmath", "statistics", "decimal", "fractions", "random", - "collections", "itertools", "functools", "heapq", "bisect", "array", "copy", - "re", "string", "textwrap", "unicodedata", - "datetime", "time", "calendar", - "base64", "hashlib", "hmac", - "json", "csv", - "uuid", "pprint", "operator", "typing", -] - - -def get_local_python_authorized_imports() -> List[str]: - """Return the imports permitted by Nexent's default local code executor.""" - from smolagents.local_python_executor import BASE_BUILTIN_MODULES - - return sorted(set(BASE_BUILTIN_MODULES) | set(SAFE_PYTHON_INTERPRETER_IMPORTS)) - - -logger = logging.getLogger(__name__) - -_WORKSPACE_UPLOAD_EXCLUDED_DIRS = { - ".skill_snapshot", - ".cache", - ".npm", - ".parcel-cache", - ".pnpm-store", - ".venv", - "__pycache__", - "node_modules", - "venv", -} - - -def cleanup_run_workspace( - workspace_path: str | None, - workspace_run_id: str | None, - logger_: logging.Logger | None = None, -) -> bool: - """Delete one exact run workspace and its empty user directory.""" - if not workspace_path or not workspace_run_id: - return False - - cleanup_logger = logger_ or logger - workspace = Path(workspace_path).resolve() - if workspace.name != workspace_run_id: - cleanup_logger.error( - "Refusing to clean workspace whose final component does not match run id: %s", - workspace, - ) - return False - - try: - removed = workspace.exists() - if removed: - shutil.rmtree(workspace) - try: - workspace.parent.rmdir() - except OSError: - pass - return removed - except Exception as exc: - cleanup_logger.error("Failed to clean run workspace %s: %s", workspace, exc) - return False - - -def _ensure_non_empty_final_answer(answer: str, lang: str) -> str: - """Return a user-visible fallback when final-answer cleanup removes all content.""" - if answer.strip(): - return answer - logger.warning("Final answer was empty after removing reasoning content") - if lang == "zh": - return "智能体未能生成有效的最终回复,请重试或换一种方式描述需求。" - return "The agent could not generate a valid final response. Please try again or rephrase your request." - - -def _tool_name(tool_obj: Any) -> str: - """Return the most useful tool name for monitoring.""" - return ( - getattr(tool_obj, "name", None) - or getattr(tool_obj, "__name__", None) - or type(tool_obj).__name__ - ) - - -def _has_host_tools(tools: List[Any]) -> bool: - """Return whether the agent has tools marked for host-process execution.""" - return any(getattr(tool, "_nexent_execute_on_host", False) for tool in tools) - - -def _is_retriever_tool(tool_obj: Any) -> bool: - """Classify tools that should use RETRIEVER rather than TOOL semantics.""" - name = type(tool_obj).__name__ - return name in ("KnowledgeBaseSearchTool", "SearchMemoryTool") - - -def _build_tool_input(callable_obj: Callable, args: tuple, kwargs: Dict[str, Any]) -> Dict[str, Any]: - """Best-effort conversion of tool call arguments into span input attributes.""" - try: - signature = inspect.signature(callable_obj) - bound = signature.bind_partial(*args, **kwargs) - return dict(bound.arguments) - except (TypeError, ValueError): - tool_input: Dict[str, Any] = {} - if args: - tool_input["args"] = list(args) - if kwargs: - tool_input.update(kwargs) - return tool_input - - -def _wrap_tool_with_monitoring(tool_obj: Any, agent_name: str) -> Any: - """Wrap smolagents tools and callables with a tool span.""" - if getattr(tool_obj, "_nexent_monitoring_wrapped", False): - return tool_obj - - monitoring_manager = get_monitoring_manager() - tool_name = _tool_name(tool_obj) - is_retriever_tool = _is_retriever_tool(tool_obj) - - def monitored_span(tool_input: Dict[str, Any]): - if is_retriever_tool: - return monitoring_manager.trace_retriever_call( - tool_name, - agent_name, - tool_input, - ) - return monitoring_manager.trace_tool_call(tool_name, agent_name, tool_input) - - def set_monitored_output(result: Any) -> None: - if is_retriever_tool: - monitoring_manager.set_retriever_output(result) - else: - monitoring_manager.set_tool_output(result) - - if hasattr(tool_obj, "forward") and callable(tool_obj.forward): - original_forward = tool_obj.forward - - if inspect.iscoroutinefunction(original_forward): - @functools.wraps(original_forward) - async def monitored_forward(*args, **kwargs): - tool_input = _build_tool_input(original_forward, args, kwargs) - with monitored_span(tool_input): - result = await original_forward(*args, **kwargs) - set_monitored_output(result) - return result - else: - @functools.wraps(original_forward) - def monitored_forward(*args, **kwargs): - tool_input = _build_tool_input(original_forward, args, kwargs) - with monitored_span(tool_input): - result = original_forward(*args, **kwargs) - set_monitored_output(result) - return result - - tool_obj.forward = monitored_forward - setattr(tool_obj, "_nexent_monitoring_wrapped", True) - return tool_obj - - if callable(tool_obj): - original_callable = tool_obj - - if inspect.iscoroutinefunction(original_callable): - @functools.wraps(original_callable) - async def monitored_callable(*args, **kwargs): - tool_input = _build_tool_input(original_callable, args, kwargs) - with monitored_span(tool_input): - result = await original_callable(*args, **kwargs) - set_monitored_output(result) - return result - else: - @functools.wraps(original_callable) - def monitored_callable(*args, **kwargs): - tool_input = _build_tool_input(original_callable, args, kwargs) - with monitored_span(tool_input): - result = original_callable(*args, **kwargs) - set_monitored_output(result) - return result - - setattr(monitored_callable, "_nexent_monitoring_wrapped", True) - return monitored_callable - - return tool_obj - - -class NexentAgent: - def __init__(self, observer: MessageObserver, - model_config_list: List[ModelConfig], - stop_event: Event, - mcp_tool_collection=None, - redis_client=None, - sandbox_config=None, - minio_client=None, - conversation_id=None, - user_id=None, - tenant_id=None, - workspace_path=None, - workspace_run_id=None, - minio_files=None, - cancellation_scope=None, - user_context=None, - tool_fastpath_enabled=True, - tool_fastpath_allowed_tools=None): - """ - Initialize the NexentAgent factory. - - Args: - observer: MessageObserver instance - model_config_list: List of model configurations - stop_event: Threading event for stop control - mcp_tool_collection: Optional MCP tool collection - redis_client: Redis client for plan persistence - sandbox_config: Optional SandboxConfig for sandbox isolation. - When None, uses LocalPythonExecutor (backwards-compatible). - minio_client: Optional MinIO client for output file sync. - Required when sandbox_config.auto_sync_outputs is True. - conversation_id: Optional conversation id for plan persistence. - user_id: Optional user id for plan persistence. - tenant_id: Optional tenant id for file isolation. - workspace_path: Run-scoped host workspace path. - workspace_run_id: Opaque run id used to validate cleanup scope. - minio_files: Authorized files attached to the current request. - cancellation_scope: Optional run-scoped cancellation registry. - user_context: Optional caller user context (tenant/user/groups) - passed through to tools for tool-side authorization. - tool_fastpath_enabled: Enable restricted local actions before Docker initialization. - tool_fastpath_allowed_tools: Approved official implementation IDs. - """ - if not isinstance(observer, MessageObserver): - raise TypeError("Create Observer Object with MessageObserver") - - self.core_run_info = None - self.observer = observer - self.model_config_list = model_config_list - self.stop_event = stop_event - self._sandbox_skill_cleanup_owner = self - self.cancellation_scope = cancellation_scope - self.mcp_tool_collection = mcp_tool_collection - self.redis_client = redis_client - self.sandbox_config = sandbox_config - self.tool_fastpath_enabled = tool_fastpath_enabled - self.tool_fastpath_allowed_tools = tool_fastpath_allowed_tools - self.minio_client = minio_client - self.conversation_id = conversation_id - self.user_id = user_id - self.tenant_id = tenant_id - self.workspace_path = workspace_path - self.workspace_mapping = None - if sandbox_config is not None and sandbox_config.workspace_mode == "bind" and workspace_path: - from .sandbox import SandboxLevel - - if sandbox_config.level == SandboxLevel.DOCKER: - self.workspace_mapping = sandbox_config.bind_workspace().for_run(workspace_path) - self.workspace_run_id = workspace_run_id - self.minio_files = list(minio_files or []) - self.user_context = dict(user_context or {}) - self._workspace_uploads: List[Dict[str, Any]] = [] - self._workspace_uploaded_paths: set[str] = set() - self._sandbox_executors: List[Any] = [] - self._sandbox_skill_runners: List[Any] = [] - self._tool_fastpath_executors: List[Any] = [] - self._subagent_invocations: Dict[Event, Any] = {} - self._subagent_invocations_lock = Lock() - self._subagent_invocations_closed = False - - self.agent = None - - def create_model(self, model_cite_name: str): - """create a model instance""" - # Filter out None values and find matching model config - model_config = next( - (model_config for model_config in self.model_config_list - if model_config is not None and model_config.cite_name == model_cite_name), - None - ) - if model_config is None: - raise ValueError(f"Model {model_cite_name} not found") - model_kwargs = dict( - observer=self.observer, - model_id=model_config.model_name, - api_key=model_config.api_key, - api_base=model_config.url, - temperature=model_config.temperature, - top_p=model_config.top_p, - ssl_verify=model_config.ssl_verify if model_config.ssl_verify is not None else True, - model_factory=model_config.model_factory, - display_name=model_config.cite_name, - extra_body=model_config.extra_body, - max_output_tokens=model_config.max_output_tokens, - timeout_seconds=model_config.timeout_seconds, - prompt_cache=model_config.prompt_cache, - reasoning_capability=model_config.reasoning_capability, - diagnostic_chunks_enabled=model_config.diagnostic_chunks_enabled, - ) - if model_config.explicit_sampling: - model_kwargs["explicit_sampling"] = model_config.explicit_sampling - if model_config.reasoning_budget_tokens is not None: - model_kwargs["reasoning_budget_tokens"] = model_config.reasoning_budget_tokens - if ( - model_config.enable_thinking - and model_config.reasoning_effort is not None - and model_config.reasoning_effort != "auto" - ): - model_kwargs["reasoning_effort"] = model_config.reasoning_effort - if self.cancellation_scope is not None: - model_kwargs["cancellation_scope"] = self.cancellation_scope - if model_config.concurrency_limit is not None: - model_kwargs["concurrency_limit"] = model_config.concurrency_limit - model_kwargs["concurrency_key"] = ( - str(self.tenant_id or "default"), - str(model_config.model_factory or "unknown"), - str(model_config.model_name), - ) - model = OpenAIModel(**model_kwargs) - model.stop_event = self.stop_event - return model - - - def create_local_tool(self, tool_config: ToolConfig): - class_name = tool_config.class_name - params = tool_config.params - tool_class = globals().get(class_name) - if tool_class is None: - raise ValueError(f"{class_name} not found in local") - else: - if class_name == "KnowledgeBaseSearchTool": - # Filter out conflicting parameters from params to avoid conflicts. - # Parameters declared with exclude=True cannot be passed to __init__ - # due to smolagents.tools.Tool wrapper restrictions; they are set as - # attributes on the instance after construction, sourced from metadata. - # `document_paths` is intentionally hidden from the LLM and only - # populated via tool_params from the northbound interface. - filtered_params = {k: v for k, v in params.items() - if k not in ["vdb_core", "embedding_model", "observer", "rerank_model", "display_name_to_index_map"]} - # Create instance with only non-excluded parameters - tools_obj = tool_class(**filtered_params) - # Set excluded parameters directly as attributes after instantiation - # This bypasses smolagents wrapper restrictions - tools_obj.observer = self.observer - tools_obj.vdb_core = tool_config.metadata.get( - "vdb_core", None) if tool_config.metadata else None - tools_obj.embedding_model = tool_config.metadata.get( - "embedding_model", None) if tool_config.metadata else None - tools_obj.rerank_model = tool_config.metadata.get( - "rerank_model", None) if tool_config.metadata else None - tools_obj.display_name_to_index_map = tool_config.metadata.get( - "display_name_to_index_map", {}) if tool_config.metadata else {} - # Internal access control: restrict results to documents whose - # path_or_url is in the allow list. Only the northbound interface - # may populate this; never the LLM. - tools_obj.set_document_paths( - tool_config.metadata.get( - "document_paths") if tool_config.metadata else None - ) - tools_obj.set_allowed_index_names( - tool_config.metadata.get("allowed_index_names") - if tool_config.metadata else None - ) - elif class_name in ["DifySearchTool", "DataMateSearchTool"]: - # These parameters have exclude=True and cannot be passed to __init__ - filtered_params = {k: v for k, v in params.items() - if k not in ["observer", "rerank_model"]} - tools_obj = tool_class(**filtered_params) - tools_obj.observer = self.observer - tools_obj.rerank_model = tool_config.metadata.get( - "rerank_model", None) if tool_config.metadata else None - elif class_name == "RAGFlowSearchTool": - # RAGFlowSearchTool does not accept rerank/rerank_model_name as - # init params — RAGFlow handles reranking internally via its API. - # The rerank_model attribute is set post-init for display and - # observability purposes only (e.g., showing model info in the UI). - filtered_params = {k: v for k, v in params.items() - if k not in ["observer", "rerank_model", "rerank", "rerank_model_name"]} - tools_obj = tool_class(**filtered_params) - tools_obj.observer = self.observer - tools_obj.rerank_model = tool_config.metadata.get( - "rerank_model", None) if tool_config.metadata else None - elif class_name == "HaotianSearchTool": - # Haotian uses reranking_enable/reranking_model_name (not rerank/rerank_model_name) - filtered_params = {k: v for k, v in params.items() - if k not in ["observer", "rerank_model", "rerank"]} - tools_obj = tool_class(**filtered_params) - tools_obj.observer = self.observer - elif class_name == "AnalyzeTextFileTool": - # Extract validate_url_access from metadata if it's callable - validate_url_access = tool_config.metadata.get("validate_url_access") if tool_config.metadata else None - if validate_url_access is not None and not callable(validate_url_access): - validate_url_access = None - tools_obj = tool_class(observer=self.observer, - llm_model=tool_config.metadata.get("llm_model", []), - storage_client=tool_config.metadata.get("storage_client", []), - data_process_service_url=tool_config.metadata.get("data_process_service_url", []), - validate_url_access=validate_url_access, - **params) - elif class_name in ["AnalyzeImageTool", "AnalyzeAudioTool", "AnalyzeVideoTool"]: - # Extract validate_url_access from metadata if it's callable - validate_url_access = tool_config.metadata.get("validate_url_access") if tool_config.metadata else None - if validate_url_access is not None and not callable(validate_url_access): - validate_url_access = None - tools_obj = tool_class(observer=self.observer, - vlm_model=tool_config.metadata.get("vlm_model", []), - storage_client=tool_config.metadata.get("storage_client", []), - validate_url_access=validate_url_access, - **params) - elif class_name in ["StoreMemoryTool", "SearchMemoryTool"]: - metadata = tool_config.metadata or {} - tools_obj = tool_class() - if tool_config.description is not None: - tools_obj.description = tool_config.description - if tool_config.inputs is not None: - tools_obj.inputs = json.loads(tool_config.inputs) - if tool_config.output_type is not None: - tools_obj.output_type = tool_config.output_type - tools_obj.observer = self.observer - tools_obj.memory_config = metadata.get( - "memory_config", {}) if metadata else {} - tools_obj.tenant_id = metadata.get( - "tenant_id", "") if metadata else "" - tools_obj.user_id = metadata.get( - "user_id", "") if metadata else "" - tools_obj.agent_id = metadata.get( - "agent_id", "") if metadata else "" - raw_conversation_id = ( - metadata.get("conversation_id", "") if metadata else "" - ) - tools_obj.conversation_id = ( - str(raw_conversation_id) - if raw_conversation_id not in (None, "") - else "" - ) - tools_obj.memory_user_config = metadata.get( - "memory_user_config", None) if metadata else None - tools_obj.memory_service = metadata.get( - "memory_service", None) if metadata else None - tools_obj.embedding_configured = metadata.get( - "embedding_configured", True - ) if metadata else True - if class_name == "SearchMemoryTool": - tools_obj.memory_context_service = metadata.get( - "memory_context_service", None) if metadata else None - else: - tools_obj.memory_context_service = None - elif class_name == "AidpSearchTool": - # kds_name_to_id_map is exclude=True; inject via metadata after init - filtered_params = {k: v for k, v in params.items() - if k not in ["kds_name_to_id_map"]} - tools_obj = tool_class(**filtered_params) - tools_obj.observer = self.observer - tools_obj.kds_name_to_id_map = tool_config.metadata.get( - "kds_name_to_id_map", {}) if tool_config.metadata else {} - # Install the KDS whitelist so the tool only retrieves from - # KBs the current user is permitted to see. Guard against - # ``metadata=None`` the same way every other branch does - # (``.get(...) if tool_config.metadata else ...``). - allowed_raw = ( - tool_config.metadata.get("allowed_kds_set") - if tool_config.metadata else None - ) - if allowed_raw is not None: - try: - tools_obj.set_allowed_kds([str(k) for k in allowed_raw]) - except Exception as exc: - logger.warning( - "Failed to install Aidp whitelist from metadata: %s; " - "falling back to an empty whitelist", exc, - ) - tools_obj.set_allowed_kds([]) - else: - # Whitelist not set by backend → treat as uninstalled. - tools_obj.set_allowed_kds(None) - elif class_name == "IndependentAidpSearchTool": - filtered_params = { - key: value - for key, value in params.items() - if key not in ["observer", "image_url_builder", "rerank_model", "rerank"] - } - tools_obj = tool_class(**filtered_params) - tools_obj.observer = self.observer - tools_obj.image_url_builder = ( - tool_config.metadata.get("image_url_builder") - if tool_config.metadata else None - ) - elif class_name in ["DownloadFromS3Tool", "UploadToS3Tool"]: - metadata = tool_config.metadata or {} - tools_obj = tool_class( - workspace_path=params.get("workspace_path", "/mnt/nexent"), - minio_client=metadata.get("minio_client"), - user_id=metadata.get("user_id", ""), - tenant_id=metadata.get("tenant_id", ""), - observer=self.observer, - ) - else: - tools_obj = tool_class(**params) - if hasattr(tools_obj, 'observer'): - tools_obj.observer = self.observer - if tool_config.inputs and hasattr(tools_obj, "inputs"): - parsed_inputs = tool_config.inputs - if isinstance(parsed_inputs, str): - try: - parsed_inputs = json.loads(parsed_inputs) - except (TypeError, ValueError): - parsed_inputs = None - if isinstance(parsed_inputs, dict): - tools_obj.inputs = parsed_inputs - if tool_config.output_type and hasattr(tools_obj, "output_type"): - tools_obj.output_type = tool_config.output_type - return tools_obj - - def create_langchain_tool(self, tool_config: ToolConfig): - tool_obj = tool_config.metadata - return Tool.from_langchain(tool_obj) - - def create_mcp_tool(self, class_name): - if self.mcp_tool_collection is None: - raise ValueError("MCP tool collection is not initialized") - tool_obj = next( - (tool for tool in self.mcp_tool_collection.tools if tool.name == class_name), - None - ) - if tool_obj is None: - raise ValueError(f"{class_name} not found in MCP server") - # Collections may be shared by multiple runs; never wrap their template. - tool_obj = copy(tool_obj) - tool_obj.inputs = deepcopy(tool_obj.inputs) - return apply_user_context_to_mcp_tool(tool_obj, self.user_context) - - def create_builtin_tool(self, tool_config: ToolConfig): - """Create a builtin tool instance. - - Args: - tool_config: Tool configuration with class_name, params, and optional metadata. - - Returns: - Tool instance - - Raises: - ValueError: If builtin tool is not found - """ - class_name = tool_config.class_name - params = tool_config.params or {} - - if class_name == "RunSkillScriptTool": - from nexent.core.tools.run_skill_script_tool import RunSkillScriptTool - metadata = tool_config.metadata or {} - kwargs = dict( - local_skills_dir=params.get("local_skills_dir"), - agent_id=metadata.get("agent_id"), - tenant_id=metadata.get("tenant_id"), - version_no=metadata.get("version_no", 0), - observer=self.observer, - authorized_skill_names=params.get("authorized_skill_names"), - ) - if params.get("isolated_skills_root"): - kwargs["isolated_skills_root"] = True - if params.get("workspace_path"): - kwargs["workspace_path"] = params["workspace_path"] - kwargs["on_complete"] = lambda _result: self._push_file_workspace_to_sandbox() - return RunSkillScriptTool(**kwargs) - elif class_name == "ReadSkillMdTool": - from nexent.core.tools.read_skill_md_tool import ReadSkillMdTool - metadata = tool_config.metadata or {} - kwargs = dict( - local_skills_dir=params.get("local_skills_dir"), - agent_id=metadata.get("agent_id"), - tenant_id=metadata.get("tenant_id"), - version_no=metadata.get("version_no", 0), - ) - if params.get("authorized_skill_names") is not None: - kwargs["authorized_skill_names"] = params["authorized_skill_names"] - if params.get("isolated_skills_root"): - kwargs["isolated_skills_root"] = True - return ReadSkillMdTool(**kwargs) - elif class_name == "ReadSkillConfigTool": - from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool - metadata = tool_config.metadata or {} - kwargs = dict( - local_skills_dir=params.get("local_skills_dir"), - agent_id=metadata.get("agent_id"), - tenant_id=metadata.get("tenant_id"), - version_no=metadata.get("version_no", 0), - config_overrides=params.get("config_overrides"), - ) - if params.get("authorized_skill_names") is not None: - kwargs["authorized_skill_names"] = params["authorized_skill_names"] - return ReadSkillConfigTool(**kwargs) - elif class_name == "DownloadFromS3Tool": - from nexent.core.tools.download_from_s3_tool import DownloadFromS3Tool - metadata = tool_config.metadata or {} - return DownloadFromS3Tool( - workspace_path=params.get("workspace_path", self.workspace_path or "/mnt/nexent/workdir"), - minio_client=metadata.get("minio_client"), - user_id=metadata.get("user_id", self.user_id or ""), - tenant_id=metadata.get("tenant_id", self.tenant_id or ""), - observer=self.observer, - validate_url_access=metadata.get("validate_url_access"), - on_download=lambda _result: self._push_file_workspace_to_sandbox(), - ) - elif class_name == "UploadToS3Tool": - from nexent.core.tools.upload_to_s3_tool import UploadToS3Tool - metadata = tool_config.metadata or {} - return UploadToS3Tool( - workspace_path=params.get("workspace_path", self.workspace_path or "/mnt/nexent/workdir"), - minio_client=metadata.get("minio_client"), - user_id=metadata.get("user_id", self.user_id or ""), - tenant_id=metadata.get("tenant_id", self.tenant_id or ""), - observer=self.observer, - run_id=metadata.get("run_id", self.workspace_run_id or ""), - on_upload=self._record_workspace_upload, - ensure_local_file=lambda _path: self._pull_file_workspace_from_sandbox(), - uploaded_paths=self._workspace_uploaded_paths, - ) - elif class_name == "CreatePlanTool": - from nexent.core.tools.plan_tools import CreatePlanTool - return CreatePlanTool() - elif class_name == "UpdatePlanStepTool": - from nexent.core.tools.plan_tools import UpdatePlanStepTool - return UpdatePlanStepTool() - elif class_name == "CreateScheduledTaskProposalTool": - from nexent.core.tools.create_scheduled_task_tool import ( - CreateScheduledTaskProposalTool, - ) - metadata = tool_config.metadata or {} - return CreateScheduledTaskProposalTool( - create_proposal=metadata.get("create_proposal"), - observer=self.observer, - ) - else: - raise ValueError(f"Unknown builtin tool: {class_name}") - - def create_tool(self, tool_config: ToolConfig): - """create a tool instance according to the tool config""" - if not isinstance(tool_config, ToolConfig): - raise TypeError("tool_config must be a ToolConfig object") - try: - class_name = tool_config.class_name - source = tool_config.source - - if source == "local": - tool_obj = self.create_local_tool(tool_config) - elif source == "mcp": - tool_obj = self.create_mcp_tool(class_name) - tool_obj = apply_mcp_tool_parameters(tool_obj, tool_config.params) - elif source == "langchain": - tool_obj = self.create_langchain_tool(tool_config) - elif source == "builtin": - tool_obj = self.create_builtin_tool(tool_config) - else: - raise ValueError(f"unsupported tool source: {source}") - mapping = getattr(self, "workspace_mapping", None) - if mapping is not None and class_name in { - "CreateFileTool", "ReadFileTool", "DeleteFileTool", "DownloadFromS3Tool", "UploadToS3Tool", - }: - tool_obj.workspace_mapping = mapping - if class_name in {"CreateFileTool", "ReadFileTool", "DeleteFileTool"}: - tool_obj.init_path = str(mapping.host_root / "outputs") - if source in {"local", "builtin", "mcp"}: - try: - setattr(tool_obj, "_nexent_execute_on_host", True) - except (AttributeError, TypeError): - pass - return tool_obj - except Exception as e: - raise ValueError(f"Error in creating tool: {e}") - - def _wrap_subagent( - self, - inner_agent: Any, - sub_agent_config: Any, - agent_id: Any = None, - *, - invocation_factory: Optional[Callable] = None, - ) -> "SubAgentToolWrapper": - """Wrap a sub-agent ``Tool`` so the observer sees nesting boundaries. - - Both internal ``AgentConfig``-derived managed agents and external - ``ExternalA2AAgentConfig``-derived wrappers funnel through here, so - every nested invocation emits ``subagent_start``/``subagent_end`` - regardless of which kind of sub-agent was added to ``managed_agents``. - """ - from .subagent_wrapper import SubAgentToolWrapper - - resolved_id = ( - agent_id - if agent_id is not None - else getattr(sub_agent_config, "agent_id", None) - or getattr(sub_agent_config, "_sub_agent_id", None) - ) - agent_name = ( - getattr(sub_agent_config, "display_name", None) - or getattr(sub_agent_config, "name", None) - or getattr(inner_agent, "name", None) - or "subagent" - ) - return SubAgentToolWrapper( - inner_agent=inner_agent, - observer=self.observer, - agent_id=resolved_id, - agent_name=str(agent_name), - runtime_identity={ - key: getattr(sub_agent_config, key, None) - for key in ("runtime_ref", "version_no", "display_name", "origin") - }, - invocation_name=( - getattr(sub_agent_config, "invocation_name", None) - or getattr(inner_agent, "name", None) - or str(agent_name) - ), - invocation_factory=invocation_factory, - ) - - def _create_subagent_definition(self, config: AgentConfig, tree_context: Dict[str, Any]): - """Register a definition; each call builds and owns a separate runtime.""" - from .subagent_wrapper import SubAgentDefinition - - definition = SubAgentDefinition(config.invocation_name or config.name, config.description) - return self._wrap_subagent( - definition, - config, - invocation_factory=lambda: self._subagent_invocation(config, definition, tree_context), - ) - - @contextmanager - def _subagent_invocation(self, config: AgentConfig, definition: Any, tree_context: Dict[str, Any]): - """Keep allocation, state, cancellation and cleanup local to one call.""" - from ..concurrency.cancellation import RunCancellationScope, RunTerminated - - scope = RunCancellationScope(parent_stop_event=self.stop_event) - done = Event() - with self._subagent_invocations_lock: - if self._subagent_invocations_closed or self.stop_event.is_set(): - raise RunTerminated("Subagent owner has stopped accepting invocations") - self._subagent_invocations[done] = scope - parent_token = None - runtime = None - drained = False - try: - if self.cancellation_scope is not None: - parent_token = self.cancellation_scope.register_closer(scope.cancel) - if scope.cancelled or self.stop_event.is_set(): - raise RunTerminated("Subagent invocation cancelled before allocation") - from .core_adapters.children import child_run_info, invoke_child - - if getattr(self, "core_run_info", None) is not None: - info = child_run_info(self.core_run_info, config, scope) - yield lambda task, **kwargs: invoke_child(info, task) - drained = True - return - runtime = type(self)( - observer=self.observer, - model_config_list=self.model_config_list, - stop_event=scope.stop_event, - mcp_tool_collection=self.mcp_tool_collection, - redis_client=self.redis_client, - sandbox_config=self.sandbox_config, - minio_client=self.minio_client, - conversation_id=self.conversation_id, - user_id=self.user_id, - tenant_id=self.tenant_id, - workspace_path=self.workspace_path, - workspace_run_id=self.workspace_run_id, - cancellation_scope=scope, - user_context=self.user_context, - tool_fastpath_enabled=self.tool_fastpath_enabled, - tool_fastpath_allowed_tools=self.tool_fastpath_allowed_tools, - ) - runtime._sandbox_skill_cleanup_owner = self._sandbox_skill_cleanup_owner - from .agent_model import copy_invocation_config - - agent = runtime.create_single_agent( - copy_invocation_config(config), _managed_context=True, _sandbox_tree_context=tree_context, - ) - runtime.set_agent(agent) - runtime._set_runtime_metadata_for_agent_tree(agent, definition.get_runtime_metadata()) - runtime._initialize_sandbox_workspaces() - if scope.cancelled: - raise RunTerminated("Subagent invocation cancelled during allocation") - yield agent - finally: - try: - if runtime is not None: - runtime._close_subagent_invocations() - drained = True - try: - if runtime.workspace_path: - runtime._pull_file_workspace_from_sandbox() - finally: - runtime._cleanup_sandbox() - else: - drained = True - finally: - if drained: - if parent_token is not None: - self.cancellation_scope.unregister_closer(parent_token) - with self._subagent_invocations_lock: - self._subagent_invocations.pop(done, None) - done.set() - - def _close_subagent_invocations(self, timeout: float = 5.0) -> None: - """Cancel and drain owned calls before shared workspace teardown.""" - with self._subagent_invocations_lock: - self._subagent_invocations_closed = True - pending = tuple(self._subagent_invocations.items()) - for _, scope in pending: - scope.cancel() - deadline = time.monotonic() + timeout - for done, _ in pending: - if not done.wait(max(0.0, deadline - time.monotonic())): - logger.error("Subagent invocation exceeded close grace; retaining owner resources") - raise RuntimeError("Subagent invocation did not terminate before owner cleanup") - - def _check_sandbox_cancelled(self) -> None: - """Stop initialization and retries after the owning run is cancelled.""" - if self.stop_event.is_set() is True: - raise CancelledError("Sandbox initialization cancelled") - - def create_single_agent( - self, - agent_config: AgentConfig, - _managed_context: bool = False, - *, - context_items_override: Sequence["ContextItemInput"] | None = None, - _sandbox_tree_context: Optional[Dict[str, Any]] = None, - ) -> CoreAgent: - """ - Build a CoreAgent from ``agent_config``. - - Args: - agent_config: AgentConfig describing this agent. - _managed_context: Internal compatibility flag for managed agents. - _sandbox_tree_context: Internal construction context used to share - one session Docker container across the agent tree while each - agent retains an isolated kernel. - """ - if not isinstance(agent_config, AgentConfig): - raise TypeError("agent_config must be a AgentConfig object") - if _sandbox_tree_context is None: - _sandbox_tree_context = {} - _sandbox_tree_context.setdefault("initialization_lock", RLock()) - - try: - self._check_sandbox_cancelled() - model = self.create_model(agent_config.model_name) - model.context_budget_snapshot = getattr( - agent_config, - "context_budget_snapshot", - None, - ) - model.capacity_snapshot = getattr( - agent_config, - "capacity_snapshot", - None, - ) - prompt_templates = agent_config.prompt_templates - - prompt_tool_policy = getattr(agent_config, "prompt_tool_policy_snapshot", None) - effective_prompt_tools = None - tool_configs = agent_config.tools - if prompt_tool_policy is not None: - from ..tools.prompt_registry import ( - PromptToolPolicySnapshot, - filter_effective_prompt_tools, - ) - - snapshot = PromptToolPolicySnapshot.from_mapping(prompt_tool_policy) - registry = {config.name: config for config in agent_config.tools if config.name} - if len(registry) != len(agent_config.tools): - raise ValueError("Agent prompt tool registry has duplicate or unnamed entries") - effective_prompt_tools = filter_effective_prompt_tools( - registry, enabled=set(snapshot.enabled), allowed=set(snapshot.allowed), - system_default_hidden=set(snapshot.system_default_hidden), - policy_version=snapshot.policy_version, - tool_schema_version=snapshot.tool_schema_version, - ) - tool_configs = list(effective_prompt_tools.execution_tools.values()) - - try: - tool_list = [ - _wrap_tool_with_monitoring( - self.create_tool(tool_config), - agent_config.name, - ) - for tool_config in tool_configs - ] - except Exception as e: - raise ValueError(f"Error in creating tool: {e}") - - try: - # Definitions are lazy; each invocation owns its runtime and kernel. - worker_agents_list = [ - self._create_subagent_definition(sub_agent_config, _sandbox_tree_context) - for sub_agent_config in agent_config.worker_agents - ] - except Exception as e: - raise ValueError(f"Error in creating worker agent: {e}") - - # Create wrapper agents for external A2A agents in the worker-agent call set. - # so model can call them like: external_agent_name(task="...") - if agent_config.external_a2a_agents: - try: - from .a2a_agent_proxy import ExternalA2AAgentWrapper - for ext_agent_config in agent_config.external_a2a_agents: - a2a_agent_info = ext_agent_config.to_a2a_agent_info() - wrapper = ExternalA2AAgentWrapper( - agent_info=a2a_agent_info, - stop_event=self.stop_event, - observer=self.observer, - cancellation_scope=self.cancellation_scope, - user_context=self.user_context, - ) - worker_agents_list.append( - self._wrap_subagent( - wrapper, - ext_agent_config, - agent_id=str(ext_agent_config.agent_id), - ) - ) - except Exception as e: - raise ValueError(f"Error in creating external A2A agent wrapper: {e}") - - # ContextManager is the only production context assembly path. - # ContextPolicy controls whether adaptive compaction is active. - from .context import ContextManager, ContextManagerConfig, ManagedContextRuntime - - ctx_config = ( - getattr(agent_config, "context_manager_config", None) - or ContextManagerConfig() - ) - context_manager = ContextManager( - config=ctx_config, - max_steps=agent_config.max_steps, - ) - context_items = apply_model_visible_tool_schemas_to_context_items( - list(context_items_override) - if context_items_override is not None - else list(getattr(agent_config, "context_items", None) or []), - tool_list, - ) - from .context import ContextItemInput, ContextItemType - - enable_clarification = ( - not _managed_context - and getattr(self.observer, "enable_nl2a_wrapper", False) is not True - and getattr(agent_config, "output_protocol", "code_action") == "code_action" - ) - if enable_clarification and any(item.type == ContextItemType.SYSTEM for item in context_items): - tool_name = choose_clarification_tool_name({tool.name for tool in [*tool_list, *worker_agents_list]}) - context_items.append(ContextItemInput( - id="system:clarification_protocol", - type=ContextItemType.SYSTEM, - content={ - "text": clarification_policy( - tool_name, - language=getattr(self.observer, "lang", "en"), - ) - }, - source=("runtime:clarification_protocol",), - priority=100, - metadata={"authority": "platform", "layout_order": 23}, - )) - if effective_prompt_tools is not None: - visible_names = set(effective_prompt_tools.tools) - context_items = [ - item for item in context_items - if getattr(getattr(item, "type", None), "value", getattr(item, "type", None)) != "tool" - or item.id.removeprefix("tool:") in visible_names - ] - self.observer.add_message( - agent_config.name, - ProcessType.OTHER, - json.dumps({"event": "prompt_tool_registry", **dict(effective_prompt_tools.audit)}), - ) - context_runtime = ManagedContextRuntime( - context_manager, - items=context_items, - ) - - # Build one code executor for this agent. Worker-agent orchestration - # is a host-marked tool, so every agent needs its own kernel to avoid - # nested execution deadlocks; session containers can still be shared. - python_executor = None - if self.sandbox_config is not None: - from .sandbox import SandboxLevel - - deferred = self.tool_fastpath_enabled and self.sandbox_config.level == SandboxLevel.DOCKER - self._sandbox_scope = self.sandbox_config.scope.value - - def release_executor(executor): - with _sandbox_tree_context["initialization_lock"]: - self._release_deferred_executor(executor) - group = _sandbox_tree_context.get("session_container_group") - if group is not None and getattr(group, "_closed", False): - _sandbox_tree_context.pop("session_container_group", None) - - def create_executor(): - python_executor = None - script_runner = None - with _sandbox_tree_context["initialization_lock"]: - try: - from .sandbox import build_python_executor - self._check_sandbox_cancelled() - if deferred and self.workspace_path: - self._pull_file_workspace_from_sandbox() - has_worker = bool( - agent_config.worker_agents - or getattr(agent_config, "external_a2a_agents", []) - ) - python_executor = build_python_executor( - config=self.sandbox_config, - logger_=logger, - worker_agents_exist=has_worker, - host_tools_exist=_has_host_tools([ - *tool_list, - *worker_agents_list, - ]), - session_container_group=_sandbox_tree_context.get( - "session_container_group" - ), - cancellation_scope=self.cancellation_scope, - ) - self._sandbox_executors.append(python_executor) - self._check_sandbox_cancelled() - session_container_group = None - if ( - self.sandbox_config.level == SandboxLevel.DOCKER - and self.sandbox_config.scope.value == "session" - ): - session_container_group = getattr( - python_executor, - "_nexent_session_container_group", - None, - ) - if session_container_group is not None: - existing_group = _sandbox_tree_context.setdefault( - "session_container_group", - session_container_group, - ) - if existing_group is not session_container_group: - raise RuntimeError( - "Agent tree received multiple session sandbox containers" - ) - if self.sandbox_config.level != SandboxLevel.LOCAL: - from .sandbox import SandboxSkillScriptRunner - - configured_timeout = getattr(self.sandbox_config, "timeout_seconds", None) - skill_timeout = ( - max(1, int(configured_timeout)) - if isinstance(configured_timeout, (int, float)) - and not isinstance(configured_timeout, bool) - else 300 - ) - script_runner = SandboxSkillScriptRunner( - python_executor, - timeout_seconds=skill_timeout, - workspace_path=self.workspace_path, - network_enabled=not self.sandbox_config.network_disabled, - workspace_mapping=getattr(self, "workspace_mapping", None), - ) - for tool in tool_list: - bind_backend = getattr(tool, "bind_execution_backend", None) - if callable(bind_backend) and _tool_name(tool) == "run_skill_script": - bind_backend( - script_runner, - on_complete=lambda _result: self._pull_file_workspace_from_sandbox(), - ) - self._sandbox_skill_runners.append(script_runner) - self._check_sandbox_cancelled() - # Warm up remote executors when they are acquired. - if self.sandbox_config.level != SandboxLevel.LOCAL: - try: - warm_start = time.time() - current_metadata = get_agent_monitoring_context() or AgentRunMetadata() - warmup_metadata = replace( - current_metadata, - agent_id=( - getattr(agent_config, "_sub_agent_id", None) - if _managed_context else current_metadata.agent_id - ), - agent_name=agent_config.name, - agent_display_name=agent_config.display_name, - model_name=agent_config.model_name, - ) - with get_monitoring_manager().trace_agent_step( - "agent.sandbox.warmup", - warmup_metadata, - step_type="sandbox_warmup", - **{ - "sandbox.level": self.sandbox_config.level.value, - "sandbox.scope": self.sandbox_config.scope.value, - "sandbox.backend": getattr(python_executor, "_nexent_backend", "unknown"), - }, - ): - self._check_sandbox_cancelled() - python_executor("[0, None]") - self._check_sandbox_cancelled() - warm_dur = time.time() - warm_start - backend = getattr(python_executor, "_nexent_backend", "unknown") - if backend == "local": - if self.sandbox_config.failure_policy == "error": - raise RuntimeError("Docker was requested but the executor is local") - self.workspace_mapping = None - for tool in tool_list: - if getattr(tool, "workspace_mapping", None) is not None: - tool.workspace_mapping = None - self.observer.add_message( - "", ProcessType.WARNING, - "Docker sandbox unavailable; execution has fallen back to LocalPythonExecutor.", - ) - logger.warning( - "Sandbox level '%s' unavailable; using LocalPythonExecutor instead " - "(scope=%s, warm-up %.2fs)", - self.sandbox_config.level.value, - self.sandbox_config.scope.value, - warm_dur, - ) - else: - logger.info( - "Sandbox warmed up in %.2fs (backend=%s, level=%s, scope=%s)", - warm_dur, - backend, - self.sandbox_config.level.value, - self.sandbox_config.scope.value, - ) - except CancelledError: - raise - except Exception as warm_err: - self._check_sandbox_cancelled() - if self.sandbox_config.failure_policy == "error": - raise RuntimeError("Sandbox FAILED phase=warmup") from warm_err - logger.warning( - "Sandbox warm-up failed (%s): %s", - self.sandbox_config.level.value, - warm_err, - ) - # Store scope on NexentAgent so _cleanup_sandbox() can read it. - self._sandbox_scope = self.sandbox_config.scope.value - - if deferred: - self._check_sandbox_cancelled() - container = getattr(python_executor, "container", None) - if container is not None: - self._push_file_workspace_to_sandbox(containers=[container]) - self._initialize_sandbox_workspaces(executors=[python_executor]) - return python_executor - except BaseException: - if deferred and python_executor is not None: - try: - if script_runner is not None: - skill_owner = self._sandbox_skill_cleanup_owner - if skill_owner is self: - script_runner.cleanup() - else: - # A failed child must not remove skills used by sibling invocations. - with skill_owner._subagent_invocations_lock: - skill_owner._sandbox_skill_runners.append(script_runner) - self._sandbox_skill_runners[:] = [ - runner for runner in self._sandbox_skill_runners - if runner is not script_runner - ] - finally: - release_executor(python_executor) - raise - - if deferred: - from .tool_fastpath import ( - OFFICIAL_FASTPATH_TOOL_CLASSES, - DeferredToolExecutor, - official_fastpath_tools, - ) - - allowed_classes = ( - OFFICIAL_FASTPATH_TOOL_CLASSES - if self.tool_fastpath_allowed_tools is None - else self.tool_fastpath_allowed_tools - ) - python_executor = DeferredToolExecutor( - create_executor=create_executor, - release_executor=release_executor, - allowed_tools=official_fastpath_tools(tool_configs, tool_list, allowed_classes), - stop_event=self.stop_event, - ) - self._tool_fastpath_executors.append(python_executor) - else: - python_executor = create_executor() - - # Create the agent - self._check_sandbox_cancelled() - agent = CoreAgent( - observer=self.observer, - tools=tool_list, - model=model, - name=agent_config.invocation_name or agent_config.name, - display_name=agent_config.display_name, - description=agent_config.description, - max_steps=agent_config.max_steps, - prompt_templates=prompt_templates, - provide_run_summary=agent_config.provide_run_summary, - managed_agents=worker_agents_list, - additional_authorized_imports=SAFE_PYTHON_INTERPRETER_IMPORTS, - instructions=agent_config.instructions, - context_runtime=context_runtime, - enable_planning=agent_config.enable_planning, - redis_client=self.redis_client, - conversation_id=self.conversation_id, - user_id=self.user_id, - executor=python_executor, - verification_config=getattr(agent_config, "verification_config", None), - output_protocol=getattr(agent_config, "output_protocol", "code_action"), - enable_protocol_repair_retry=agent_config.enable_protocol_repair_retry, - enable_clarification=enable_clarification, - workspace_path=self.workspace_path, - ) - agent.stop_event = self.stop_event - - # Wire plan tool deps if the plan tools are present in agent_config.tools. - # CoreAgent already knows enable_planning (set above); use that to gate wiring. - if agent.enable_planning: - if (create_plan := agent.tools.get("create_plan")) is not None: - create_plan.observer = agent.observer - create_plan.plan_repo = agent.plan_repo - create_plan._on_plan_created = agent._on_plan_created - create_plan._get_conversation_id = agent._get_conversation_id - create_plan._get_user_id = agent._get_user_id - if (update_step := agent.tools.get("update_plan_step")) is not None: - update_step.observer = agent.observer - update_step.plan_repo = agent.plan_repo - update_step._on_step_updated = agent._on_step_updated - update_step._get_conversation_id = agent._get_conversation_id - update_step._get_user_id = agent._get_user_id - - return agent - except CancelledError: - self._cleanup_sandbox() - raise - except Exception as e: - if getattr(self.sandbox_config, "failure_policy", None) == "error": - try: - self._cleanup_sandbox() - except Exception: - logger.exception("Failed to release sandbox resources after agent construction failed") - raise ValueError(f"Error in creating agent, agent name: {agent_config.name}, Error: {e}") - - def add_history_to_agent(self, history: List[AgentHistory]): - """ - Add conversation history to agent's memory - - Args: - history: List of conversation messages with role and content - """ - if history is None: - return - - if not isinstance(self.agent, CoreAgent): - raise TypeError(f"agent must be a CoreAgent object, not {type(self.agent)}") - - if not all(isinstance(msg, AgentHistory) for msg in history): - raise TypeError("history must be a list of AgentHistory objects") - - self.agent.memory.reset() - # Add conversation history to memory sequentially - for msg in history: - if msg.role == 'user': - # Create task step for user message - self.agent.memory.steps.append(TaskStep(task=msg.content)) - elif msg.role == 'assistant': - self.agent.memory.steps.append(ActionStep(step_number=len(self.agent.memory.steps) + 1, - timing=Timing(start_time=time.time()), - action_output=msg.content, model_output=msg.content)) - - self.agent._history_step_count = len(self.agent.memory.steps) - @staticmethod - def _set_runtime_metadata_for_agent_tree(root_agent: CoreAgent, metadata: Dict[str, Any]): - """Set isolated runtime metadata on internal and external sub-agents.""" - - snapshots = [] - pending = [root_agent] - visited = set() - while pending: - current = pending.pop() - if not isinstance(current, CoreAgent) or id(current) in visited: - continue - visited.add(id(current)) - snapshots.append(("core", current, "metadata" in current.state, current.state.get("metadata"))) - current.state["metadata"] = deepcopy(metadata) - children = getattr(current, "managed_agents", {}) or {} - if isinstance(children, dict): - child_values = children.values() - elif isinstance(children, (list, tuple)): - child_values = children - else: - child_values = () - for child in child_values: - inner_agent = ( - child - if isinstance(child, CoreAgent) - else getattr(child, "_inner", child) - ) - if isinstance(inner_agent, CoreAgent): - pending.append(inner_agent) - continue - - set_runtime_metadata = getattr(inner_agent, "set_runtime_metadata", None) - get_runtime_metadata = getattr(inner_agent, "get_runtime_metadata", None) - if callable(set_runtime_metadata) and callable(get_runtime_metadata): - snapshots.append(("external", inner_agent, True, get_runtime_metadata())) - set_runtime_metadata(deepcopy(metadata)) - return snapshots - - @staticmethod - def _restore_runtime_metadata_for_agent_tree(snapshots) -> None: - """Restore agent state after one run, including failure and cancellation.""" - - for agent_type, agent, existed, previous_value in snapshots: - if agent_type == "external": - agent.set_runtime_metadata(previous_value) - elif existed: - agent.state["metadata"] = previous_value - else: - agent.state.pop("metadata", None) - - def agent_run_with_observer( - self, - query: str, - reset: bool = True, - additional_args: Optional[Dict[str, Any]] = None, - ): - if not isinstance(self.agent, CoreAgent): - raise TypeError(f"agent must be a CoreAgent object, not {type(self.agent)}") - - monitoring_manager = get_monitoring_manager() - current_metadata = get_agent_monitoring_context() or AgentRunMetadata() - metadata = replace( - current_metadata, - agent_name=current_metadata.agent_name or getattr(self.agent, "name", None) or self.agent.agent_name, - agent_display_name=current_metadata.agent_display_name or getattr(self.agent, "display_name", None), - query=current_metadata.query if current_metadata.query is not None else query, - ) - observer = self.agent.observer - total_output_tokens = 0 - final_answer_for_trace = None - with monitoring_manager.start_agent_run(metadata): - with monitoring_manager.trace_agent_step( - "agent.run.loop", - metadata, - step_type="agent_loop", - ): - runtime_state_snapshots = [] - try: - query = self._prepare_file_workspace(query) - runtime_state_snapshots = self._set_runtime_metadata_for_agent_tree( - self.agent, - (additional_args or {}).get("metadata", {}), - ) - step_log = None - run_kwargs = {"stream": True, "reset": reset} - if additional_args is not None: - run_kwargs["additional_args"] = additional_args - for step_log in self.agent.run(query, **run_kwargs): - # Add content to observer - if not isinstance(step_log, ActionStep): - continue - - # Real tool-call chunks are emitted by CoreAgent - # (_emit_real_tool_chunks_from_code) right after the - # PARSE chunk, so we deliberately skip re-emitting them - # here to avoid duplicating the synthetic - # ``python_interpreter`` ToolCall that smolagents - # stamps on every action step. - - # Emit token stats after each action step - step_duration = getattr(step_log.timing, "duration", None) - step_input = None - step_output = None - if hasattr(step_log, "token_usage") and step_log.token_usage is not None: - step_input = getattr(step_log.token_usage, "input_tokens", None) - step_output = getattr(step_log.token_usage, "output_tokens", None) - if step_output: - total_output_tokens += step_output - - estimated_context = None - last_metric = None - if hasattr(self.agent, "step_metrics") and self.agent.step_metrics: - last_metric = self.agent.step_metrics[-1] - estimated_context = last_metric.get( - "memory_state", {} - ).get("estimated_input_tokens") - - token_threshold = None - context_window_tokens = None - effective_input_limit_tokens = None - context_processing_mode = None - context_runtime = getattr(self.agent, "context_runtime", None) - if context_runtime is not None: - token_threshold = context_runtime.token_threshold - context_window_tokens = context_runtime.context_window_tokens - effective_input_limit_tokens = context_runtime.effective_input_limit_tokens - context_processing_mode = context_runtime.processing_mode - - token_data = { - "step_number": step_log.step_number, - "duration": round(float(step_duration), 2) if step_duration is not None else 0.0, - "step_input_tokens": step_input, - "step_output_tokens": step_output, - "total_output_tokens": total_output_tokens, - "estimated_context_tokens": estimated_context, - "token_threshold": token_threshold, - "context_window_tokens": context_window_tokens, - "effective_input_limit_tokens": effective_input_limit_tokens, - "context_processing_mode": context_processing_mode, - "output_finish_reason": getattr( - getattr(self.agent, "model", None), - "last_finish_reason", - None, - ), - } - if last_metric: - compression = last_metric.get("compression", {}) or {} - token_data.update({ - "compression_calls": compression.get("calls", 0), - "compression_input_tokens": compression.get("input_tokens", 0), - "compression_output_tokens": compression.get("output_tokens", 0), - "compression_cache_hits": compression.get("cache_hits", 0), - "compression_cache_types": compression.get("cache_types", []), - "compression_ratio": last_metric.get("compression_ratio", 0.0), - "uncompressed_est_tokens": last_metric.get("uncompressed_mem_est_input", 0), - }) - active_model = getattr(self.agent, "model", None) - cache_usage = getattr(active_model, "last_prompt_cache_usage", None) - cache_advice = getattr(active_model, "last_provider_cache_advice", None) - if cache_usage is not None: - metrics_source = getattr(cache_usage, "metrics_source", "capability_unknown") - metrics_available = metrics_source not in {"none", "capability_unknown"} - capability_supported = bool(getattr(cache_advice, "supported", False)) - token_data.update({ - "provider_cache_status": ( - "available" if metrics_available else - "unavailable" if capability_supported else - "unsupported" - ), - "provider_cache_metrics_source": metrics_source, - "provider_cache_hit": bool(getattr(cache_usage, "provider_cache_hit", False)), - "provider_cached_input_tokens": int( - getattr(cache_usage, "cached_input_tokens", 0) or 0 - ), - "provider_uncached_input_tokens": int( - getattr(cache_usage, "uncached_input_tokens", 0) or 0 - ), - }) - observer.add_message("", ProcessType.TOKEN_COUNT, json.dumps(token_data)) - - if ( - hasattr(step_log, "error") - and step_log.error is not None - and not getattr(step_log, "_suppress_user_error", False) - ): - # Action-step failures are observations in the ReAct loop: - # the model receives them and can repair/retry on the next - # step. Surface them as warnings so the UI does not imply - # that the whole run has already failed. - observer.add_message("", ProcessType.WARNING, str(step_log.error)) - - if step_log is None: - raise ValueError("Agent run produced no output") - - final_answer = step_log.output # Last log is the run's final_answer - - if isinstance(final_answer, AgentText): - final_answer_str = convert_code_format(final_answer.to_string()) - else: - # prepare for multi-modal final_answer - final_answer_str = convert_code_format(str(final_answer)) - final_answer_str = re.sub( - THINK_TAG_PATTERN, "", final_answer_str, flags=re.DOTALL | re.IGNORECASE) - # Remove thinking prefix content (until two newlines) - final_answer_str = re.sub( - THINK_PREFIX_PATTERN, "", final_answer_str, flags=re.DOTALL) - final_answer_str = _ensure_non_empty_final_answer( - final_answer_str, - getattr(observer, "lang", "en"), - ) - final_answer_for_trace = final_answer_str - monitoring_manager.set_openinference_output(final_answer_str) - observer.complete_final_answer(self.agent.agent_name, final_answer_str) - - # Check if we need to stop from external stop_event - if self.agent.stop_event.is_set(): - observer.add_message(self.agent.agent_name, ProcessType.WARNING, - "Agent execution interrupted by external stop signal") - except ModelOutputProtocolExhaustedError as e: - observer.add_message( - agent_name=self.agent.agent_name, - process_type=ProcessType.ERROR, - content=str(e), - error_code="model_output_protocol_exhausted", - retryable=False, - ) - raise - except ModelInvocationTerminalError as e: - observer.add_message( - agent_name=self.agent.agent_name, - process_type=ProcessType.ERROR, - content=e.safe_message(getattr(observer, "lang", "en")), - error_code=e.error_code.value, - retryable=False, - ) - raise - except Exception as e: - if is_mcp_timeout_error(e): - raise - observer.add_message(agent_name=self.agent.agent_name, process_type=ProcessType.ERROR, - content=f"Error in interaction: {str(e)}") - raise ValueError(f"Error in interaction: {str(e)}") - - finally: - self._close_subagent_invocations() - self._restore_runtime_metadata_for_agent_tree(runtime_state_snapshots) - self._log_step_metrics() - try: - self._finalize_file_workspace() - finally: - self._cleanup_file_workspace() - self._cleanup_sandbox() - - if final_answer_for_trace is not None: - if hasattr(self.agent, "step_metrics"): - monitoring_manager.set_agent_context_metrics(self.agent.step_metrics) - monitoring_manager.set_openinference_output(final_answer_for_trace) - return final_answer_for_trace - - def _record_workspace_upload(self, upload: Dict[str, Any]) -> None: - """Collect one upload result for the frontend artifact event.""" - object_name = str(upload.get("object_name") or "") - if object_name and any(item.get("object_name") == object_name for item in self._workspace_uploads): - return - self._workspace_uploads.append(upload) - - def _prepare_file_workspace(self, query: str) -> str: - """Create the run workspace and download current-request attachments.""" - if not self.workspace_path: - return query - - workspace = Path(self.workspace_path) - (workspace / "inputs").mkdir(parents=True, exist_ok=True) - (workspace / "outputs").mkdir(parents=True, exist_ok=True) - download_tool = (getattr(self.agent, "tools", {}) or {}).get("download_from_s3") - downloaded: List[Dict[str, str]] = [] - if self.minio_files and download_tool is None: - raise RuntimeError("Uploaded files are present but download_from_s3 is unavailable") - - for index, item in enumerate(self.minio_files): - if not isinstance(item, dict): - continue - object_name = str(item.get("object_name") or "").strip().lstrip("/") - source_url = str(item.get("url") or "").strip() - if object_name: - bucket = getattr(getattr(download_tool, "minio_client", None), "default_bucket", None) or "nexent" - source_url = f"s3://{bucket}/{object_name}" - if not source_url: - continue - filename = os.path.basename(str(item.get("name") or object_name or f"file_{index}")) - local_filename = f"inputs/{index:03d}_{filename}" - result = json.loads(download_tool.forward(source_url, local_filename)) - downloaded.append({"name": filename, "path": result["local_path"]}) - - language = getattr(self.observer, "lang", "en") - mapping = getattr(self, "workspace_mapping", None) - display_workspace = mapping.container_root if mapping is not None else workspace - workspace_note = render_user_context(language, "workspace_note", { - "workspace": display_workspace, - }) - self._push_file_workspace_to_sandbox() - self._initialize_sandbox_workspaces() - return query + workspace_note - - def _sandbox_container(self) -> Any: - """Return the active Docker container when the executor exposes one.""" - containers = self._sandbox_containers() - return containers[0] if containers else None - - def _sandbox_containers(self) -> List[Any]: - """Return every distinct Docker container used by this agent tree.""" - executors = list(self._sandbox_executors) - root_executor = getattr(self.agent, "python_executor", None) - if root_executor is not None and all( - executor is not root_executor for executor in executors - ): - executors.append(root_executor) - - containers: List[Any] = [] - seen_keys = set() - for executor in executors: - container = getattr(executor, "container", None) - if container is None: - continue - container_id = getattr(container, "id", None) - key = container_id if isinstance(container_id, str) and container_id else id(container) - if key in seen_keys: - continue - seen_keys.add(key) - containers.append(container) - return containers - - def _uses_shared_file_workspace(self) -> bool: - """Return whether the runtime and sandbox use the same workspace volume.""" - if getattr(self, "workspace_mapping", None) is not None: - return True - extra_kwargs = getattr(self.sandbox_config, "extra_kwargs", {}) or {} - return bool( - extra_kwargs.get("shared_workspace") - and extra_kwargs.get("workspace_volume_name") - ) - - def _push_file_workspace_to_sandbox(self, containers: Optional[List[Any]] = None) -> None: - """Copy the prepared host workspace into every Docker sandbox.""" - if containers is None: - containers = self._sandbox_containers() - if not containers or not self.workspace_path: - return - workspace = Path(self.workspace_path).resolve() - mapping = getattr(self, "workspace_mapping", None) - if mapping is not None: - self._verify_bind_workspace_access(containers, workspace, mapping) - return - if not workspace.exists() or workspace.drive: - return - shared_workspace = self._uses_shared_file_workspace() - archive_bytes = None - if not shared_workspace: - archive = io.BytesIO() - with tarfile.open(fileobj=archive, mode="w") as tar: - tar.add(workspace, arcname=str(workspace).lstrip("/"), recursive=True) - archive_bytes = archive.getvalue() - - for container in containers: - if archive_bytes is not None and not container.put_archive("/", archive_bytes): - raise RuntimeError("Failed to copy run workspace into the sandbox") - self._grant_sandbox_output_access(container, workspace) - - def _verify_bind_workspace_access(self, containers, workspace: Path, mapping: SandboxWorkspace) -> None: - """Probe the mounted run directory without copying or changing Windows ACLs.""" - for container in containers: - try: - if not workspace.drive: - self._grant_sandbox_output_access(container, mapping.container_root) - probe_workspace(container, mapping.container_root) - except Exception: - logger.exception( - "Sandbox FAILED phase=workspace_access run_id=%s container_id=%s", - self.workspace_run_id, getattr(container, "id", None), - ) - raise - - def _initialize_sandbox_workspaces(self, executors: Optional[List[Any]] = None) -> None: - """Set every Docker kernel's cwd and workspace environment for this run.""" - if not self.workspace_path: - return - workspace = Path(self.workspace_path).resolve() - mapping = getattr(self, "workspace_mapping", None) - if mapping is not None: - workspace = mapping.container_root - output_dir = workspace / "outputs" - bootstrap_code = ( - "import os as _nexent_os\n" - f"_nexent_workspace = {json.dumps(str(workspace))}\n" - f"_nexent_output_dir = {json.dumps(str(output_dir))}\n" - "_nexent_os.environ['NEXENT_WORKSPACE'] = _nexent_workspace\n" - "_nexent_os.environ['NEXENT_OUTPUT_DIR'] = _nexent_output_dir\n" - "_nexent_os.chdir(_nexent_output_dir)\n" - "[_nexent_workspace, _nexent_output_dir]" - ) - seen_executor_ids = set() - for executor in self._sandbox_executors if executors is None else executors: - self._check_sandbox_cancelled() - executor_id = id(executor) - if executor_id in seen_executor_ids: - continue - seen_executor_ids.add(executor_id) - backend = getattr(executor, "_nexent_backend", None) - if backend == "local": - continue - if backend != "docker" and getattr(executor, "container", None) is None: - continue - register_bootstrap = None - if ( - getattr(executor, "_nexent_kernel_recovery_supported", False) - is True - and callable( - getattr(type(executor), "register_kernel_bootstrap_code", None) - ) - ): - register_bootstrap = executor.register_kernel_bootstrap_code - execute_bootstrap = ( - register_bootstrap if callable(register_bootstrap) else executor - ) - try: - execute_bootstrap(bootstrap_code) - self._check_sandbox_cancelled() - logger.info( - "Sandbox READY run_id=%s actual_backend=docker scope=%s container_id=%s kernel_id=%s " - "host_workspace=%s container_workspace=%s", - self.workspace_run_id, getattr(getattr(self.sandbox_config, "scope", None), "value", None), - getattr(getattr(executor, "container", None), "id", None), - getattr(executor, "kernel_id", None), self.workspace_path, workspace, - ) - except CancelledError: - raise - except Exception as exc: - self._check_sandbox_cancelled() - # Workspace initialization is idempotent. If the kernel channel - # failed and marked this lease unhealthy, retry the bootstrap in - # the same run so the lease can replace its kernel immediately. - # Do not apply this retry to arbitrary generated code because a - # lost terminal message does not prove that code had no effects. - if ( - getattr(executor, "_nexent_kernel_recovery_supported", False) - and getattr(executor, "_unhealthy", False) - # Registered bootstraps already own their bounded recovery. - and not callable(register_bootstrap) - ): - logger.warning( - "Retrying sandbox workspace initialization with a replacement kernel: %s", - exc, - exc_info=True, - ) - try: - execute_bootstrap(bootstrap_code) - self._check_sandbox_cancelled() - continue - except CancelledError: - raise - except Exception as retry_exc: - self._check_sandbox_cancelled() - exc = retry_exc - logger.exception( - "Sandbox FAILED phase=workspace run_id=%s: %s", self.workspace_run_id, exc, - exc_info=(type(exc), exc, exc.__traceback__), - ) - raise RuntimeError( - f"Failed to initialize sandbox workspace '{workspace}': {exc}" - ) from exc - - @staticmethod - def _grant_sandbox_output_access(container: Any, workspace: Path) -> None: - """Allow sandbox traversal of the user directory and writes in the run workspace.""" - gid_result = container.exec_run(["id", "-g"]) - gid_exit_code = getattr(gid_result, "exit_code", None) - gid_output = getattr(gid_result, "output", b"") - if gid_exit_code != 0: - raise RuntimeError("Failed to determine the sandbox user's group") - - if isinstance(gid_output, bytes): - gid_output = gid_output.decode("utf-8", errors="replace") - sandbox_gid = str(gid_output).strip() - if not sandbox_gid.isdigit(): - raise RuntimeError("Sandbox user returned an invalid group ID") - - workspace_dir = str(workspace) - workspace_parent_dir = str(workspace.parent) - commands = ( - ["chgrp", sandbox_gid, workspace_parent_dir], - ["chmod", "g+xs", workspace_parent_dir], - ["chgrp", "-R", sandbox_gid, workspace_dir], - ["chmod", "-R", "g+rwX", workspace_dir], - ["find", workspace_dir, "-type", "d", "-exec", "chmod", "g+s", "{}", "+"], - ) - for command in commands: - result = container.exec_run(command, user="0") - if getattr(result, "exit_code", None) != 0: - output = getattr(result, "output", b"") - if isinstance(output, bytes): - output = output.decode("utf-8", errors="replace") - raise RuntimeError( - f"Failed to grant sandbox output access: {str(output).strip()}" - ) - - def _pull_file_workspace_from_sandbox(self) -> None: - """Copy outputs from every Docker sandbox back to the host workspace.""" - containers = self._sandbox_containers() - if not containers or not self.workspace_path: - return - if self._uses_shared_file_workspace(): - return - workspace = Path(self.workspace_path).resolve() - if workspace.drive: - return - for container in containers: - try: - chunks, _ = container.get_archive(str(workspace)) - archive = io.BytesIO(b"".join(chunks)) - with tarfile.open(fileobj=archive, mode="r:*") as tar: - members = [] - extraction_root = workspace.parent.resolve() - for member in tar.getmembers(): - if not (member.isfile() or member.isdir()): - raise RuntimeError("Sandbox workspace archive contains an unsupported entry") - target = (extraction_root / member.name).resolve() - try: - target.relative_to(extraction_root) - except ValueError as exc: - raise RuntimeError("Sandbox workspace archive escapes the run root") from exc - members.append(member) - tar.extractall(extraction_root, members=members) - except Exception as exc: - logger.warning("Failed to copy sandbox workspace back to host: %s", exc) - - def _finalize_file_workspace(self) -> None: - """Upload all workspace outputs that were not already uploaded explicitly.""" - if not self.workspace_path: - return - self._pull_file_workspace_from_sandbox() - workspace = Path(self.workspace_path) - upload_tool = (getattr(self.agent, "tools", {}) or {}).get("upload_to_s3") - if upload_tool is None: - logger.error("Run workspace exists but upload_to_s3 is unavailable") - return - - uploaded_paths = getattr(upload_tool, "uploaded_paths", set()) - for path in workspace.rglob("*") if workspace.exists() else (): - if not path.is_file(): - continue - relative = path.relative_to(workspace) - if relative.parts and relative.parts[0] in {"inputs", "skills"}: - continue - if any(part in _WORKSPACE_UPLOAD_EXCLUDED_DIRS for part in relative.parts[:-1]): - continue - normalized = os.path.normcase(os.path.abspath(str(path))) - if normalized in uploaded_paths: - continue - try: - upload_tool.forward(str(path), relative.as_posix()) - except Exception as exc: - logger.error("Failed to upload workspace output %s: %s", path, exc) - self.observer.add_message( - "", - ProcessType.WARNING, - f"Failed to upload output file {relative}: {exc}", - ) - - if self._workspace_uploads: - self.observer.add_message( - "", - ProcessType.FILE_ARTIFACT, - {"artifacts": list(self._workspace_uploads)}, - ) - - def _cleanup_file_workspace(self) -> None: - """Delete only the exact run-scoped workspace after upload finalization.""" - if not self.workspace_path or not self.workspace_run_id: - return - workspace = Path(self.workspace_path).resolve() - if workspace.name != self.workspace_run_id: - return - try: - containers = [] if getattr(self, "workspace_mapping", None) is not None else self._sandbox_containers() - for container in containers: - try: - result = container.exec_run( - ["rm", "-rf", "--", str(workspace)], - user="0", - ) - if getattr(result, "exit_code", None) != 0: - output = getattr(result, "output", b"") - if isinstance(output, bytes): - output = output.decode("utf-8", errors="replace") - logger.warning( - "Failed to clean sandbox run workspace %s: %s", - workspace, - str(output).strip(), - ) - except Exception as exc: - logger.warning("Failed to clean sandbox run workspace %s: %s", workspace, exc) - cleanup_run_workspace( - self.workspace_path, - self.workspace_run_id, - logger, - ) - except Exception as exc: - logger.error("Failed to clean run workspace %s: %s", workspace, exc) - - def set_agent(self, agent: CoreAgent): - if not isinstance(agent, CoreAgent): - raise TypeError(f"agent must be a CoreAgent object, not {type(agent)}") - self.agent = agent - - def _log_step_metrics(self): - """Output step_metrics to log or local file for quantitative analysis of context management.""" - if not hasattr(self.agent, "step_metrics") or not self.agent.step_metrics: - return - - metrics = self.agent.step_metrics - - # Pre-collect all values - real_i_vals = [m['main_llm']['input_tokens'] for m in metrics] - real_o_vals = [m['main_llm']['output_tokens'] for m in metrics] - comp_i_vals = [m['compression']['input_tokens'] for m in metrics] - comp_o_vals = [m['compression']['output_tokens'] for m in metrics] - est_i_vals = [m['memory_state']['estimated_input_tokens'] for m in metrics] - est_o_vals = [m['memory_state']['estimated_output_tokens'] for m in metrics] - raw_i_vals = [m['uncompressed_mem_est_input'] for m in metrics] - save_vals = [f"{m['compression_ratio']}%" for m in metrics] - hit_vals = [str(m['cache_hit']) for m in metrics] - - # Total summary - total_ri = sum(real_i_vals) - total_ro = sum(real_o_vals) - total_ci = sum(comp_i_vals) - total_co = sum(comp_o_vals) - total_ei = sum(est_i_vals) - total_eo = sum(est_o_vals) - total_raw = sum(raw_i_vals) - hit_count = sum(1 for m in metrics if m['cache_hit']) - - if total_raw > 0: - total_save_str = f"{round((1 - total_ei / total_raw) * 100, 1)}%" - else: - total_save_str = "N/A" - hit_total_str = f"{hit_count}/{len(metrics)}" - - # Column widths based on max value width - def _val_width(vals, extra_val=None): - w = 0 - for v in vals: - w = max(w, len(str(v))) - if extra_val is not None: - w = max(w, len(str(extra_val))) - return w - - w_ri = _val_width(real_i_vals, total_ri) - w_ro = _val_width(real_o_vals, total_ro) - w_ci = _val_width(comp_i_vals, total_ci) - w_co = _val_width(comp_o_vals, total_co) - w_ei = _val_width(est_i_vals, total_ei) - w_eo = _val_width(est_o_vals, total_eo) - w_raw = _val_width(raw_i_vals, total_raw) - w_save = _val_width(save_vals, total_save_str) - w_hit = _val_width(hit_vals, hit_total_str) - - # Prefix formatting - max_step_digits = max(len(str(m['step_number'])) for m in metrics) - step_prefix_fmt = f"Step {{:>{max_step_digits}}}: " - total_prefix = "Total: " + " " * max_step_digits - - lines = [] - for i, m in enumerate(metrics): - lines.append( - step_prefix_fmt.format(m['step_number']) + - f"real_i={real_i_vals[i]:>{w_ri}} real_o={real_o_vals[i]:>{w_ro}} | " - f"comp_i={comp_i_vals[i]:>{w_ci}} comp_o={comp_o_vals[i]:>{w_co}} | " - f"est_i={est_i_vals[i]:>{w_ei}} est_o={est_o_vals[i]:>{w_eo}} | " - f"est_raw_i={raw_i_vals[i]:>{w_raw}} save={save_vals[i]:>{w_save}} | " - f"hit={hit_vals[i]:>{w_hit}}" - ) - - lines.append( - total_prefix + - f"real_i={total_ri:>{w_ri}} real_o={total_ro:>{w_ro}} | " - f"comp_i={total_ci:>{w_ci}} comp_o={total_co:>{w_co}} | " - f"est_i={total_ei:>{w_ei}} est_o={total_eo:>{w_eo}} | " - f"est_raw_i={total_raw:>{w_raw}} save={total_save_str:>{w_save}} | " - f"hit={hit_total_str:>{w_hit}}" - ) - context_runtime = getattr(self.agent, "context_runtime", None) - if context_runtime is not None: - lines.append(f"Context Manager Global: {context_runtime.global_compression_stats()}") - - lines.append( - "-----" - ) - logger.debug("\n".join(lines)) - - # Optional: write to local file - with open("nexent_context_metrics.log", "a", encoding="utf-8") as f: - f.write("\n".join(lines) + "\n") - - def _release_deferred_executor(self, executor: Any) -> None: - """Release a deferred executor through its original lifecycle owner.""" - self._sandbox_executors[:] = [item for item in self._sandbox_executors if item is not executor] - if getattr(self, "_sandbox_scope", None) == "system": - from .sandbox import release_python_executor - - release_python_executor(executor, logger) - else: - from .sandbox import cleanup_executor - - cleanup_executor(executor, logger, timeout=5.0) - - def _cleanup_sandbox(self) -> None: - """ - Clean up the sandbox executor after an agent run. - - For ``scope=session``: the executor is immediately destroyed. - For ``scope=system``: the executor is returned to the warm pool for reuse. - - Must run AFTER any output-sync logic, because the container filesystem - is inaccessible after the executor is released / destroyed. - """ - from .tool_fastpath import DeferredToolExecutor - - wrappers = list(self._tool_fastpath_executors) - root_executor = getattr(self.agent, "python_executor", None) - executors = list(self._sandbox_executors) - if root_executor is not None and not isinstance(root_executor, DeferredToolExecutor) and all( - item is not root_executor for item in executors - ): - executors.append(root_executor) - if not executors and not wrappers: - return - - scope = getattr(self, "_sandbox_scope", None) - - skill_owner = getattr(self, "_sandbox_skill_cleanup_owner", self) - if skill_owner is self: - for runner in self._sandbox_skill_runners: - runner.cleanup() - else: - # Shared skills remain available until the root drains every child. - with skill_owner._subagent_invocations_lock: - skill_owner._sandbox_skill_runners.extend(self._sandbox_skill_runners) - self._sandbox_skill_runners.clear() - - # Sync outputs to MinIO before destroying the container. - if ( - executors - and not self.workspace_path - and self.sandbox_config is not None - and self.sandbox_config.auto_sync_outputs - and self.minio_client is not None - ): - from .sandbox import _sync_outputs_to_minio - agent_run_id = getattr(self.agent, "agent_run_id", None) or "unknown" - try: - uploaded = _sync_outputs_to_minio( - output_dir=self.sandbox_config.output_dir, - agent_run_id=agent_run_id, - minio_client=self.minio_client, - bucket="nexent-artifacts", - logger_=logger, - ) - if uploaded: - logger.info( - "Synced %d output file(s) to MinIO for run %s", - len(uploaded), - agent_run_id, - ) - except Exception as exc: - logger.error("Output sync to MinIO failed: %s", exc) - - # Release or destroy the executor. - wrapped_executor_ids = {id(wrapper.remote) for wrapper in wrappers if wrapper.remote is not None} - seen_executor_ids = set() - for executor in reversed(executors): - executor_id = id(executor) - if executor_id in seen_executor_ids or executor_id in wrapped_executor_ids: - continue - seen_executor_ids.add(executor_id) - if scope == "system": - # Return every kernel lease to the shared system sandbox. - from .sandbox import release_python_executor - release_python_executor(executor, logger) - else: - # Session kernel leases are released independently; their - # agent-tree container is deleted after the final lease closes. - from .sandbox import cleanup_executor - cleanup_executor(executor, logger, timeout=5.0) - - for wrapper in reversed(wrappers): - wrapper.cleanup() - self._tool_fastpath_executors.clear() - - # Clear the reference so GC can collect the wrapper objects. - if self.agent is not None: - self.agent.python_executor = None - self._sandbox_executors.clear() diff --git a/sdk/nexent/core/agents/orchestration/__init__.py b/sdk/nexent/core/agents/orchestration/__init__.py new file mode 100644 index 0000000000..e2a481a22a --- /dev/null +++ b/sdk/nexent/core/agents/orchestration/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Agent orchestration namespace; implementations load on demand.""" diff --git a/sdk/nexent/core/agents/core_adapters/children.py b/sdk/nexent/core/agents/orchestration/children.py similarity index 75% rename from sdk/nexent/core/agents/core_adapters/children.py rename to sdk/nexent/core/agents/orchestration/children.py index 231ceb5b48..7006b3fc17 100644 --- a/sdk/nexent/core/agents/core_adapters/children.py +++ b/sdk/nexent/core/agents/orchestration/children.py @@ -1,9 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Resolve one child invocation without inheriting parent model or conversation state.""" from copy import deepcopy -from ..agent_model import copy_invocation_config -from .factory import AgentCoreFactory +from nexent.core.agents.contracts.config import copy_invocation_config +from nexent.core.agents.orchestration.execution import execute_adapter def child_run_info(parent, config, scope): @@ -33,10 +35,12 @@ def invoke_child(info, task): if not isinstance(task, str) or not task.strip(): raise ValueError("Child task must be a non-empty string") info.query = task - adapter = AgentCoreFactory.create(info) - try: - adapter.validate() - adapter.prepare() - return adapter.run() - finally: - adapter.close() + return execute_adapter(info) + + +def child_invocation_factory(parent): + """Inject frozen child selection without a resource-to-factory dependency.""" + def create(config, scope): + info = child_run_info(parent, config, scope) + return lambda task, **kwargs: invoke_child(info, task) + return create diff --git a/sdk/nexent/core/agents/orchestration/entry.py b/sdk/nexent/core/agents/orchestration/entry.py new file mode 100644 index 0000000000..fa3d1b4b78 --- /dev/null +++ b/sdk/nexent/core/agents/orchestration/entry.py @@ -0,0 +1,299 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + + +import asyncio +import logging +import threading +from concurrent.futures import CancelledError +from contextvars import Context, copy_context +from dataclasses import replace + +from nexent.consts.mcp_errors import is_mcp_connection_timeout_error, is_mcp_timeout_error +from nexent.core.agents.contracts.adapter import ModelOutputProtocolExhaustedError +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.orchestration.execution import ( + _emit_uncertainty_reserve_warning, + _get_authorized_context_items, + _get_authorized_history, + _get_default_agent_thread_manager, + build_run_additional_args, + execute_adapter, + shutdown_default_agent_thread_manager, +) +from nexent.core.agents.resources.mcp import ( + _create_mcp_http_client_without_proxy, + _detect_transport, + _normalize_mcp_config, + _resolve_mcp_request_timeout_seconds, +) +from nexent.core.agents.resources.workspace import cleanup_run_workspace +from nexent.core.concurrency import ( + ManagedExecution, + ManagedTaskSpec, + RunCancellationScope, + ThreadManager, + get_current_thread_manager, +) +from nexent.core.concurrency.cancellation import RunTerminated +from nexent.core.concurrency.context import _reset_current_thread_manager, _set_current_thread_manager +from nexent.core.model_errors import ModelInvocationTerminalError +from nexent.core.utils.observer import ProcessType +from nexent.monitor import ( + AgentRunMetadata, + get_agent_monitoring_context, + get_monitoring_manager, + set_monitoring_capacity_snapshot, + set_monitoring_context_budget_snapshot, +) + + +logger = logging.getLogger("run_agent") + + +def _mcp_timeout_message(agent_run_info: AgentRunInfo) -> str: + """Return a localized MCP tool-execution timeout message.""" + timeout_label = f"{agent_run_info.mcp_tool_timeout_seconds:g}" + if getattr(agent_run_info.observer, "lang", "en") == "zh": + return f"MCP 工具调用超时({timeout_label} 秒)。请确认服务响应状态后重试。" + return ( + f"MCP tool execution timed out after {timeout_label} seconds. " + "Please check the service response and try again." + ) + + +def _mcp_connection_timeout_message(agent_run_info: AgentRunInfo) -> str: + """Return a localized MCP connection-timeout message for the current run.""" + timeout_label = f"{_resolve_mcp_request_timeout_seconds(agent_run_info):g}" + if getattr(agent_run_info.observer, "lang", "en") == "zh": + return f"MCP 服务连接超时({timeout_label} 秒)。请确认服务地址和网络连通性后重试。" + return ( + f"MCP connection timed out after {timeout_label} seconds. " + "Please check the service address and network connectivity, then try again." + ) + + +def _is_mcp_timeout_error(error: BaseException) -> bool: + """Identify an MCP timeout, including errors wrapped by the Agent runtime.""" + return is_mcp_timeout_error(error) + + +def _agent_run_error_message( + agent_run_info: AgentRunInfo, + mcp_host: list | None, + error: BaseException, +) -> str: + """Build the user-facing message for a failed Agent run.""" + if mcp_host and is_mcp_connection_timeout_error(error): + return _mcp_connection_timeout_message(agent_run_info) + if mcp_host and is_mcp_timeout_error(error): + return _mcp_timeout_message(agent_run_info) + if "Couldn't connect to the MCP server" in str(error): + return ( + "MCP服务器连接超时。" + if agent_run_info.observer.lang == "zh" + else "Couldn't connect to the MCP server." + ) + return f"Run Agent Error: {error}" + + +class DeferredAgentRun: + """Managed worker target that waits for request preparation to bind run data.""" + + def __init__(self): + self._ready = threading.Event() + self._lock = threading.Lock() + self._agent_run_info: AgentRunInfo | None = None + self._context: Context | None = None + self._cancelled = False + + def bind(self, agent_run_info: AgentRunInfo) -> None: + with self._lock: + if self._agent_run_info is not None: + raise RuntimeError("Deferred agent run is already bound") + self._agent_run_info = agent_run_info + self._context = copy_context() + cancelled = self._cancelled + self._ready.set() + if cancelled: + agent_run_info.cancellation_scope.cancel() + + def cancel(self) -> None: + with self._lock: + self._cancelled = True + agent_run_info = self._agent_run_info + self._ready.set() + if agent_run_info is not None: + agent_run_info.cancellation_scope.cancel() + + def run(self, cancel_event: threading.Event) -> None: + while not self._ready.wait(0.05): + if cancel_event.is_set(): + self.cancel() + return + with self._lock: + agent_run_info = self._agent_run_info + context = self._context + self._context = None + cancelled = self._cancelled or cancel_event.is_set() + if agent_run_info is None: + return + if cancelled: + agent_run_info.cancellation_scope.cancel() + return + # Admission precedes request preparation. Use the binding-time trace + # and metadata without shadowing the worker's managed execution owner. + manager = get_current_thread_manager() + + def run_bound(): + token = _set_current_thread_manager(manager) + try: + agent_run_thread(agent_run_info) + finally: + _reset_current_thread_manager(token) + + if context is not None: + context.run(run_bound) + + +def agent_run_thread(agent_run_info: AgentRunInfo): + """Trace the complete SDK worker, including setup and resource cleanup.""" + current = get_agent_monitoring_context() or AgentRunMetadata() + metadata = replace( + current, + agent_name=current.agent_name or getattr(agent_run_info.agent_config, "name", None), + agent_display_name=current.agent_display_name or getattr(agent_run_info.agent_config, "display_name", None), + query=current.query if current.query is not None else agent_run_info.query, + ) + with get_monitoring_manager().start_agent_run(metadata): + _agent_run_thread(agent_run_info) + + +def _agent_run_thread(agent_run_info: AgentRunInfo): + mcp_host = getattr(agent_run_info, "mcp_host", None) + if hasattr(agent_run_info, "run_result"): + agent_run_info.run_result = None + try: + # Keep the runner compatible with legacy/lightweight AgentRunInfo + # stand-ins used by integrations. Full AgentRunInfo instances always + # define this field, but its absence must not prevent a run that does + # not need tool-side authorization context. + set_monitoring_capacity_snapshot( + getattr(agent_run_info, "capacity_snapshot", None) + ) + set_monitoring_context_budget_snapshot( + getattr(agent_run_info, "context_budget_snapshot", None) + ) + _emit_uncertainty_reserve_warning(agent_run_info) + mcp_host = agent_run_info.mcp_host + execute_adapter(agent_run_info) + + from nexent.core.agents.contracts.output import RunResult + + result = getattr(agent_run_info, "run_result", None) + agent_run_info.attempt_outcome = result.status if isinstance(result, RunResult) else ( + "stopped" if agent_run_info.stop_event.is_set() else "completed") + except (RunTerminated, CancelledError): + agent_run_info.attempt_outcome = "stopped" + except (ModelInvocationTerminalError, ModelOutputProtocolExhaustedError): + agent_run_info.attempt_outcome = "failed" + raise + except Exception as e: + agent_run_info.attempt_outcome = "failed" + if getattr(agent_run_info, "run_result", None) is None: + agent_run_info.observer.add_message( + "", ProcessType.FINAL_ANSWER, _agent_run_error_message(agent_run_info, mcp_host, e), + ) + raise ValueError(f"Error in agent_run_thread: {e}") + finally: + # Agent construction, MCP setup, and executor initialization can fail + # before NexentAgent.agent_run_with_observer() enters its own cleanup + # block. This idempotent outer guard owns the complete worker lifetime. + cleanup_run_workspace( + getattr(agent_run_info, "workspace_path", None), + getattr(agent_run_info, "workspace_run_id", None), + logger, + ) + + +async def agent_run( + agent_run_info: AgentRunInfo, + thread_manager: ThreadManager | None = None, + execution: ManagedExecution | None = None, + deferred_run: DeferredAgentRun | None = None, +): + observer = agent_run_info.observer + manager = thread_manager or _get_default_agent_thread_manager() + run_id = getattr(agent_run_info, "workspace_run_id", None) + if run_id is None and getattr(agent_run_info, "conversation_id", None) is not None: + run_id = str(agent_run_info.conversation_id) + runtime_metadata = getattr(agent_run_info, "runtime_metadata", {}) or {} + if agent_run_info.cancellation_scope is None: + agent_run_info.cancellation_scope = RunCancellationScope(agent_run_info.stop_event) + agent_run_info.thread_manager = manager + if execution is None: + execution = manager.submit( + "agent-run", + ManagedTaskSpec( + task_name="agent-run", + owner="nexent.core.agents.orchestration.entry", + run_id=run_id, + attempt_id=runtime_metadata.get("attempt_id"), + close_hook=agent_run_info.cancellation_scope.cancel, + ), + agent_run_thread, + agent_run_info, + ) + elif deferred_run is None: + raise ValueError("deferred_run is required with a pre-admitted execution") + else: + deferred_run.bind(agent_run_info) + agent_run_info.thread_execution_id = execution.execution_id + agent_run_info.thread_future = execution.future + + worker_finished = False + try: + while not execution.future.done(): + if agent_run_info.stop_event.is_set(): + # Stop consuming the old turn even while provider I/O is blocked. + # The finally block cancels its managed worker and close hooks. + raise asyncio.CancelledError + cached_message = observer.get_cached_message() + for message in cached_message: + yield message + if len(cached_message) < 8: + await asyncio.sleep(0.05) + await asyncio.sleep(0.1) + worker_finished = True + + # Consume the exception so the Future does not emit an unobserved failure. + # agent_run_thread has already converted it into an observer error message. + try: + execution.future.result() + except Exception: + logger.debug( + "event=agent_managed_execution_failed execution_id=%s run_id=%s", + execution.execution_id, + run_id or "", + exc_info=True, + ) + + cached_message = observer.get_cached_message() + for message in cached_message: + yield message + finally: + if not worker_finished and not execution.future.done(): + agent_run_info.stop_event.set() + manager.cancel( + execution.execution_id, + reason="agent stream consumer closed", + wait_timeout=0, + mark_stuck_on_timeout=False, + ) + + +__all__ = [ + "agent_run", "agent_run_thread", "DeferredAgentRun", "shutdown_default_agent_thread_manager", + "build_run_additional_args", "_get_authorized_context_items", "_get_authorized_history", + "_get_default_agent_thread_manager", "_emit_uncertainty_reserve_warning", "_normalize_mcp_config", + "_detect_transport", "_create_mcp_http_client_without_proxy", +] diff --git a/sdk/nexent/core/agents/orchestration/execution.py b/sdk/nexent/core/agents/orchestration/execution.py new file mode 100644 index 0000000000..b05f779fe5 --- /dev/null +++ b/sdk/nexent/core/agents/orchestration/execution.py @@ -0,0 +1,101 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Common run context helpers and Adapter lifetime; no engine loop.""" +import json +import logging +from copy import deepcopy +from typing import Any, Dict + +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.concurrency import ThreadManager +from nexent.core.concurrency.helpers import get_fallback_thread_manager, shutdown_fallback_thread_manager +from nexent.core.utils.observer import ProcessType + + +logger = logging.getLogger("run_agent") + +def _get_default_agent_thread_manager() -> ThreadManager: + """Return a bounded fallback for direct SDK callers. + + Backend services must inject their process-local manager. The fallback keeps + the public SDK call compatible and can be closed explicitly with + ``shutdown_default_agent_thread_manager``. + """ + return get_fallback_thread_manager() + + +async def shutdown_default_agent_thread_manager(timeout: float = 15.0): + """Close the direct-SDK fallback manager when an embedding process exits.""" + return await shutdown_fallback_thread_manager(timeout=timeout) + + +def build_run_additional_args(agent_run_info: AgentRunInfo) -> Dict[str, Any]: + """Build isolated variables for one agent run, including empty metadata.""" + + return {"metadata": deepcopy(agent_run_info.runtime_metadata)} + + +def _get_authorized_context_items(agent_run_info: AgentRunInfo): + """Return the run snapshot, falling back for direct SDK callers.""" + context_input = getattr(agent_run_info, "context_input", None) + if context_input is not None: + return tuple(context_input.items) + return getattr(agent_run_info.agent_config, "context_items", None) + + +def _get_authorized_history(agent_run_info: AgentRunInfo): + """Return the run snapshot, falling back for direct SDK callers.""" + context_input = getattr(agent_run_info, "context_input", None) + if context_input is not None: + # Historical runs are ContextItems. AgentMemory is reserved for this run. + return [] + return agent_run_info.history + + +def _emit_uncertainty_reserve_warning(agent_run_info: AgentRunInfo) -> None: + snapshot = getattr(agent_run_info, "context_budget_snapshot", None) + if snapshot is None: + return + warnings = snapshot.warnings + if "uncertainty_reserve_active" not in warnings: + return + + payload = { + "code": "uncertainty_reserve_active", + "message": ( + "W2 applied the unified 10% uncertainty reserve because selected " + "model capability behavior is not fully verified." + ), + "budget_fingerprint": snapshot.fingerprint, + "w1_fingerprint": snapshot.w1_fingerprint, + "uncertainty_reserve_tokens": snapshot.uncertainty_reserve_tokens, + "effective_input_limit_tokens": snapshot.effective_input_limit_tokens, + } + logger.warning( + "W2 uncertainty reserve active: budget_fingerprint=%s w1_fingerprint=%s " + "uncertainty_reserve_tokens=%s effective_input_limit_tokens=%s", + payload["budget_fingerprint"], + payload["w1_fingerprint"], + payload["uncertainty_reserve_tokens"], + payload["effective_input_limit_tokens"], + ) + try: + agent_run_info.observer.add_message( + "", + ProcessType.OTHER, + json.dumps(payload, ensure_ascii=False), + ) + except Exception: + logger.debug("Failed to emit W2 uncertainty reserve observer warning", exc_info=True) + + +def execute_adapter(info): + """Release an Adapter even when validation or preparation raises.""" + from nexent.core.agents.factory import AgentCoreFactory + adapter = AgentCoreFactory.create(info) + try: + adapter.validate() + adapter.prepare() + return adapter.run() + finally: + adapter.close() diff --git a/sdk/nexent/core/agents/output/__init__.py b/sdk/nexent/core/agents/output/__init__.py new file mode 100644 index 0000000000..cce45f4a5e --- /dev/null +++ b/sdk/nexent/core/agents/output/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Agent output namespace; implementations load on demand.""" diff --git a/sdk/nexent/core/agent_runtime_support/answer.py b/sdk/nexent/core/agents/output/answer.py similarity index 100% rename from sdk/nexent/core/agent_runtime_support/answer.py rename to sdk/nexent/core/agents/output/answer.py diff --git a/sdk/nexent/core/agents/prompt/clarification.py b/sdk/nexent/core/agents/prompt/clarification.py new file mode 100644 index 0000000000..9e3fc86812 --- /dev/null +++ b/sdk/nexent/core/agents/prompt/clarification.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Load existing clarification prompts without changing their contents.""" + +from nexent.core.prompts import load_prompt + + +CLARIFICATION_SCHEMA_GUIDANCE = load_prompt("en", "agent/human_interaction")["questions_description"] +CLARIFICATION_POLICY = load_prompt("en", "agent/human_interaction")["clarification_policy"] + + +def clarification_policy(tool_name: str, language: str = "en") -> str: + """Describe the selected terminal form name without registering a Python tool.""" + policy = load_prompt(language, "agent/human_interaction")["clarification_policy"] + return policy.replace("ask_user", tool_name) diff --git a/sdk/nexent/core/agents/resources/__init__.py b/sdk/nexent/core/agents/resources/__init__.py new file mode 100644 index 0000000000..11582bd5c0 --- /dev/null +++ b/sdk/nexent/core/agents/resources/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Shared run-scoped Agent resource capabilities.""" diff --git a/sdk/nexent/core/agents/a2a_agent_proxy.py b/sdk/nexent/core/agents/resources/a2a.py similarity index 99% rename from sdk/nexent/core/agents/a2a_agent_proxy.py rename to sdk/nexent/core/agents/resources/a2a.py index 01c48679e6..47d844adf4 100644 --- a/sdk/nexent/core/agents/a2a_agent_proxy.py +++ b/sdk/nexent/core/agents/resources/a2a.py @@ -6,6 +6,7 @@ This tool allows Nexent agents to call external A2A agents as sub-agents. It provides a unified interface for invoking remote A2A endpoints. """ + import asyncio import json import logging @@ -18,7 +19,8 @@ import httpx -from ..concurrency import ManagedTaskSpec, RunCancellationScope, get_current_thread_manager +from nexent.core.concurrency import ManagedTaskSpec, RunCancellationScope, get_current_thread_manager + # Protocol type constants (must match backend/management/db/a2a/repository.py definitions) PROTOCOL_JSONRPC = "JSONRPC" @@ -406,14 +408,14 @@ def run_in_new_loop(): # Route the private event loop through the isolated tool lane. manager = get_current_thread_manager() if manager is None: - from .run_agent import _get_default_agent_thread_manager + from nexent.core.concurrency.helpers import get_fallback_thread_manager - manager = _get_default_agent_thread_manager() + manager = get_fallback_thread_manager() return manager.run_sync( "model-tool-io", ManagedTaskSpec( task_name="a2a-sync-call", - owner="nexent.core.agents.a2a_agent_proxy", + owner="nexent.core.agents.resources.a2a", close_hook=self.cancel_active, ), run_in_new_loop, diff --git a/sdk/nexent/core/agents/resources/child_owner.py b/sdk/nexent/core/agents/resources/child_owner.py new file mode 100644 index 0000000000..8b7a4a7038 --- /dev/null +++ b/sdk/nexent/core/agents/resources/child_owner.py @@ -0,0 +1,146 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + + +from __future__ import annotations + +import logging +import time +from contextlib import contextmanager +from threading import Event +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional + +from nexent.core.agents.contracts.config import AgentConfig + + +logger = logging.getLogger(__name__) + + +if TYPE_CHECKING: + from nexent.core.agents.resources.subagents import SubAgentToolWrapper + +class SubagentResources: + """Track child scopes while dispatch is supplied by the composition owner.""" + + def _wrap_subagent( + self, + inner_agent: Any, + sub_agent_config: Any, + agent_id: Any = None, + *, + invocation_factory: Optional[Callable] = None, + ) -> "SubAgentToolWrapper": + """Wrap a sub-agent ``Tool`` so the observer sees nesting boundaries. + + Both internal ``AgentConfig``-derived managed agents and external + ``ExternalA2AAgentConfig``-derived wrappers funnel through here, so + every nested invocation emits ``subagent_start``/``subagent_end`` + regardless of which kind of sub-agent was added to ``managed_agents``. + """ + + from nexent.core.agents.resources.subagents import SubAgentToolWrapper + + resolved_id = ( + agent_id + if agent_id is not None + else getattr(sub_agent_config, "agent_id", None) + or getattr(sub_agent_config, "_sub_agent_id", None) + ) + agent_name = ( + getattr(sub_agent_config, "display_name", None) + or getattr(sub_agent_config, "name", None) + or getattr(inner_agent, "name", None) + or "subagent" + ) + return SubAgentToolWrapper( + inner_agent=inner_agent, + observer=self.observer, + agent_id=resolved_id, + agent_name=str(agent_name), + runtime_identity={ + key: getattr(sub_agent_config, key, None) + for key in ("runtime_ref", "version_no", "display_name", "origin") + }, + invocation_name=( + getattr(sub_agent_config, "invocation_name", None) + or getattr(inner_agent, "name", None) + or str(agent_name) + ), + invocation_factory=invocation_factory, + ) + + + def _create_subagent_definition(self, config: AgentConfig, tree_context: Dict[str, Any]): + """Register a definition; each call builds and owns a separate runtime.""" + from nexent.core.agents.resources.subagents import SubAgentDefinition + + definition = SubAgentDefinition(config.invocation_name or config.name, config.description) + return self._wrap_subagent( + definition, + config, + invocation_factory=lambda: self._subagent_invocation(config, definition, tree_context), + ) + + + @contextmanager + def _subagent_invocation(self, config: AgentConfig, definition: Any, tree_context: Dict[str, Any]): + """Keep allocation, state, cancellation and cleanup local to one call.""" + from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated + + scope = RunCancellationScope(parent_stop_event=self.stop_event) + done = Event() + with self._subagent_invocations_lock: + if self._subagent_invocations_closed or self.stop_event.is_set(): + raise RunTerminated("Subagent owner has stopped accepting invocations") + self._subagent_invocations[done] = scope + parent_token = None + drained = False + allocated = False + + def mark_drained(): + nonlocal drained + drained = True + try: + if self.cancellation_scope is not None: + parent_token = self.cancellation_scope.register_closer(scope.cancel) + if scope.cancelled or self.stop_event.is_set(): + raise RunTerminated("Subagent invocation cancelled before allocation") + if getattr(self, "_child_invocation_factory", None) is not None: + try: + yield self._child_invocation_factory(config, scope) + finally: + mark_drained() + return + allocated = True + with self._legacy_subagent_invocation(config, definition, tree_context, scope, mark_drained) as agent: + yield agent + finally: + try: + if not allocated: + drained = True + finally: + if drained: + if parent_token is not None: + self.cancellation_scope.unregister_closer(parent_token) + with self._subagent_invocations_lock: + self._subagent_invocations.pop(done, None) + done.set() + + + def _close_subagent_invocations(self, timeout: float = 5.0) -> None: + """Cancel and drain owned calls before shared workspace teardown.""" + with self._subagent_invocations_lock: + self._subagent_invocations_closed = True + pending = tuple(self._subagent_invocations.items()) + for _, scope in pending: + scope.cancel() + deadline = time.monotonic() + timeout + for done, _ in pending: + if not done.wait(max(0.0, deadline - time.monotonic())): + logger.error("Subagent invocation exceeded close grace; retaining owner resources") + raise RuntimeError("Subagent invocation did not terminate before owner cleanup") + + @contextmanager + def _legacy_subagent_invocation(self, config, definition, tree_context, scope, mark_drained): + mark_drained() + raise RuntimeError("Child execution requires an injected invocation factory") + yield diff --git a/sdk/nexent/core/agents/resources/compatibility/__init__.py b/sdk/nexent/core/agents/resources/compatibility/__init__.py new file mode 100644 index 0000000000..11582bd5c0 --- /dev/null +++ b/sdk/nexent/core/agents/resources/compatibility/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Shared run-scoped Agent resource capabilities.""" diff --git a/sdk/nexent/core/agents/resources/compatibility/smolagents_tools.py b/sdk/nexent/core/agents/resources/compatibility/smolagents_tools.py new file mode 100644 index 0000000000..bda56ec8a2 --- /dev/null +++ b/sdk/nexent/core/agents/resources/compatibility/smolagents_tools.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit compatibility with existing tool and sandbox execution types.""" + +from smolagents.tools import Tool + + +def deferred_executor_type(): + from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor + return DeferredToolExecutor + + +def from_langchain(tool): + return Tool.from_langchain(tool) + + +def mcp_adapter_factory(): + from mcpadapt.smolagents_adapter import SmolAgentsAdapter + return SmolAgentsAdapter(structured_output=False) diff --git a/sdk/nexent/core/agents/managed_mcp.py b/sdk/nexent/core/agents/resources/mcp.py similarity index 75% rename from sdk/nexent/core/agents/managed_mcp.py rename to sdk/nexent/core/agents/resources/mcp.py index 00208a40fb..ac7195c693 100644 --- a/sdk/nexent/core/agents/managed_mcp.py +++ b/sdk/nexent/core/agents/resources/mcp.py @@ -1,16 +1,23 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + + from __future__ import annotations import asyncio import logging +import math import threading import time from concurrent.futures import Future as ConcurrentFuture from concurrent.futures import TimeoutError as FutureTimeoutError from contextlib import AsyncExitStack -from typing import Any, Callable +from typing import Any, Callable, Dict, Union + +import httpx -from ..concurrency import ManagedExecution, ManagedTaskSpec, RunCancellationScope, ThreadManager -from ...consts.mcp_errors import MCPConnectionTimeoutError, MCPToolTimeoutError +from nexent.consts.mcp_errors import MCPConnectionTimeoutError, MCPToolTimeoutError +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.concurrency import ManagedExecution, ManagedTaskSpec, RunCancellationScope, ThreadManager logger = logging.getLogger("managed_mcp") @@ -76,9 +83,9 @@ def _session_factory(self): def _adapter_factory(self): if self._tool_adapter_factory is not None: return self._tool_adapter_factory - from mcpadapt.smolagents_adapter import SmolAgentsAdapter + from nexent.core.agents.resources.compatibility.smolagents_tools import mcp_adapter_factory - return lambda: SmolAgentsAdapter(structured_output=False) + return mcp_adapter_factory def _call_tool(self, session: Any, name: str, arguments: dict | None = None) -> Any: with self._lock: @@ -174,7 +181,7 @@ def managed_forward( "model-tool-io", ManagedTaskSpec( task_name=__task_name, - owner="nexent.core.agents.managed_mcp", + owner="nexent.core.agents.resources.mcp", close_hook=self.request_close, ), __forward, @@ -243,7 +250,7 @@ def __enter__(self): "mcp-session", ManagedTaskSpec( task_name="mcp-session", - owner="nexent.core.agents.managed_mcp", + owner="nexent.core.agents.resources.mcp", close_hook=self.request_close, pass_cancel_event=True, ), @@ -334,3 +341,100 @@ def close(self) -> None: def __exit__(self, exc_type, exc_value, traceback): self.close() return False + + +def _detect_transport(url: str) -> str: + """ + Auto-detect MCP transport type based on URL format. + + Args: + url: MCP server URL + + Returns: + Transport type: 'sse' or 'streamable-http' + """ + url_stripped = url.strip() + + if url_stripped.endswith("/sse"): + return "sse" + elif url_stripped.endswith("/mcp"): + return "streamable-http" + + return "streamable-http" + + +def _create_mcp_http_client_without_proxy( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, +) -> httpx.AsyncClient: + """Create an MCP HTTP client that ignores proxy environment variables.""" + return httpx.AsyncClient( + headers=headers, + timeout=timeout, + auth=auth, + follow_redirects=True, + trust_env=False, + ) + + +def _normalize_mcp_config(mcp_host_item: Union[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + Normalize MCP host configuration to a dictionary format. + + Args: + mcp_host_item: Either a string URL or a dict with 'url', optional 'transport', + 'headers', 'authorization', or 'httpx_client_factory' + + Returns: + Dictionary with 'url', 'transport', and supported transport options + """ + if isinstance(mcp_host_item, str): + url = mcp_host_item + transport = _detect_transport(url) + return {"url": url, "transport": transport} + elif isinstance(mcp_host_item, dict): + url = mcp_host_item.get("url") + if not url: + raise ValueError("MCP host dict must contain 'url' key") + transport = mcp_host_item.get("transport") + if not transport: + transport = _detect_transport(url) + if transport not in ("sse", "streamable-http"): + raise ValueError(f"Invalid transport type: {transport}. Must be 'sse' or 'streamable-http'") + + result = {"url": url, "transport": transport} + + if mcp_host_item.get("bypass_proxy") is True: + result["httpx_client_factory"] = _create_mcp_http_client_without_proxy + + if "authorization" in mcp_host_item and "headers" in mcp_host_item: + headers = mcp_host_item["headers"].copy() if isinstance(mcp_host_item["headers"], dict) else {} + headers["Authorization"] = mcp_host_item["authorization"] + result["headers"] = headers + elif "authorization" in mcp_host_item: + result["headers"] = {"Authorization": mcp_host_item["authorization"]} + elif "headers" in mcp_host_item: + result["headers"] = mcp_host_item["headers"] + + if "httpx_client_factory" in mcp_host_item: + httpx_client_factory = mcp_host_item["httpx_client_factory"] + if not callable(httpx_client_factory): + raise ValueError("httpx_client_factory must be callable") + result["httpx_client_factory"] = httpx_client_factory + + return result + else: + raise ValueError(f"Invalid MCP host item type: {type(mcp_host_item)}. Must be str or dict") + + +def _resolve_mcp_request_timeout_seconds(agent_run_info: AgentRunInfo) -> float: + """Return a positive finite timeout for one MCP tool request.""" + timeout = getattr(agent_run_info, "mcp_request_timeout_seconds", None) + try: + timeout = float(timeout) + except (TypeError, ValueError): + timeout = 10.0 + if not math.isfinite(timeout) or timeout <= 0: + return 10.0 + return timeout diff --git a/sdk/nexent/core/agents/resources/monitoring.py b/sdk/nexent/core/agents/resources/monitoring.py new file mode 100644 index 0000000000..dd4d757fee --- /dev/null +++ b/sdk/nexent/core/agents/resources/monitoring.py @@ -0,0 +1,113 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Existing tool observation and monitoring wrappers.""" + +import functools +import inspect +from typing import Any, Callable, Dict + +from nexent.monitor import get_monitoring_manager + + +def _tool_name(tool_obj: Any) -> str: + """Return the most useful tool name for monitoring.""" + return ( + getattr(tool_obj, "name", None) + or getattr(tool_obj, "__name__", None) + or type(tool_obj).__name__ + ) + + +def _is_retriever_tool(tool_obj: Any) -> bool: + """Classify tools that should use RETRIEVER rather than TOOL semantics.""" + name = type(tool_obj).__name__ + return name in ("KnowledgeBaseSearchTool", "SearchMemoryTool") + + +def _build_tool_input(callable_obj: Callable, args: tuple, kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Best-effort conversion of tool call arguments into span input attributes.""" + try: + signature = inspect.signature(callable_obj) + bound = signature.bind_partial(*args, **kwargs) + return dict(bound.arguments) + except (TypeError, ValueError): + tool_input: Dict[str, Any] = {} + if args: + tool_input["args"] = list(args) + if kwargs: + tool_input.update(kwargs) + return tool_input + + +def _wrap_tool_with_monitoring(tool_obj: Any, agent_name: str) -> Any: + """Wrap smolagents tools and callables with a tool span.""" + if getattr(tool_obj, "_nexent_monitoring_wrapped", False): + return tool_obj + + monitoring_manager = get_monitoring_manager() + tool_name = _tool_name(tool_obj) + is_retriever_tool = _is_retriever_tool(tool_obj) + + def monitored_span(tool_input: Dict[str, Any]): + if is_retriever_tool: + return monitoring_manager.trace_retriever_call( + tool_name, + agent_name, + tool_input, + ) + return monitoring_manager.trace_tool_call(tool_name, agent_name, tool_input) + + def set_monitored_output(result: Any) -> None: + if is_retriever_tool: + monitoring_manager.set_retriever_output(result) + else: + monitoring_manager.set_tool_output(result) + + if hasattr(tool_obj, "forward") and callable(tool_obj.forward): + original_forward = tool_obj.forward + + if inspect.iscoroutinefunction(original_forward): + @functools.wraps(original_forward) + async def monitored_forward(*args, **kwargs): + tool_input = _build_tool_input(original_forward, args, kwargs) + with monitored_span(tool_input): + result = await original_forward(*args, **kwargs) + set_monitored_output(result) + return result + else: + @functools.wraps(original_forward) + def monitored_forward(*args, **kwargs): + tool_input = _build_tool_input(original_forward, args, kwargs) + with monitored_span(tool_input): + result = original_forward(*args, **kwargs) + set_monitored_output(result) + return result + + tool_obj.forward = monitored_forward + setattr(tool_obj, "_nexent_monitoring_wrapped", True) + return tool_obj + + if callable(tool_obj): + original_callable = tool_obj + + if inspect.iscoroutinefunction(original_callable): + @functools.wraps(original_callable) + async def monitored_callable(*args, **kwargs): + tool_input = _build_tool_input(original_callable, args, kwargs) + with monitored_span(tool_input): + result = await original_callable(*args, **kwargs) + set_monitored_output(result) + return result + else: + @functools.wraps(original_callable) + def monitored_callable(*args, **kwargs): + tool_input = _build_tool_input(original_callable, args, kwargs) + with monitored_span(tool_input): + result = original_callable(*args, **kwargs) + set_monitored_output(result) + return result + + setattr(monitored_callable, "_nexent_monitoring_wrapped", True) + return monitored_callable + + return tool_obj diff --git a/sdk/nexent/core/agents/resources/provider.py b/sdk/nexent/core/agents/resources/provider.py new file mode 100644 index 0000000000..b2b210653e --- /dev/null +++ b/sdk/nexent/core/agents/resources/provider.py @@ -0,0 +1,118 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + + +from __future__ import annotations + +import logging +from threading import Event, Lock +from typing import Any, Dict, List + +from nexent.core.agents.contracts.config import ModelConfig +from nexent.core.agents.resources.child_owner import SubagentResources +from nexent.core.agents.resources.tools import ToolResources +from nexent.core.agents.resources.workspace import WorkspaceResources +from nexent.core.utils.observer import MessageObserver + + +logger = logging.getLogger(__name__) + + +class AgentResources(ToolResources, WorkspaceResources, SubagentResources): + """Run-local resource state shared by execution-core adapters.""" + + def __init__(self, observer: MessageObserver, + model_config_list: List[ModelConfig], + stop_event: Event, + mcp_tool_collection=None, + redis_client=None, + sandbox_config=None, + minio_client=None, + conversation_id=None, + user_id=None, + tenant_id=None, + workspace_path=None, + workspace_run_id=None, + minio_files=None, + cancellation_scope=None, + user_context=None, + tool_fastpath_enabled=True, + tool_fastpath_allowed_tools=None, child_invocation_factory=None): + """ + Initialize the shared run-scoped resources. + + Args: + observer: MessageObserver instance + model_config_list: List of model configurations + stop_event: Threading event for stop control + mcp_tool_collection: Optional MCP tool collection + redis_client: Redis client for plan persistence + sandbox_config: Optional SandboxConfig for sandbox isolation. + When None, uses LocalPythonExecutor (backwards-compatible). + minio_client: Optional MinIO client for output file sync. + Required when sandbox_config.auto_sync_outputs is True. + conversation_id: Optional conversation id for plan persistence. + user_id: Optional user id for plan persistence. + tenant_id: Optional tenant id for file isolation. + workspace_path: Run-scoped host workspace path. + workspace_run_id: Opaque run id used to validate cleanup scope. + minio_files: Authorized files attached to the current request. + cancellation_scope: Optional run-scoped cancellation registry. + user_context: Optional caller user context (tenant/user/groups) + passed through to tools for tool-side authorization. + tool_fastpath_enabled: Enable restricted local actions before Docker initialization. + tool_fastpath_allowed_tools: Approved official implementation IDs. + """ + if not isinstance(observer, MessageObserver): + raise TypeError("Create Observer Object with MessageObserver") + + self._child_invocation_factory = child_invocation_factory + self._resources_closed = False + self.core_run_info = None + self.observer = observer + self.model_config_list = model_config_list + self.stop_event = stop_event + self._sandbox_skill_cleanup_owner = self + self.cancellation_scope = cancellation_scope + self.mcp_tool_collection = mcp_tool_collection + self.redis_client = redis_client + self.sandbox_config = sandbox_config + self.tool_fastpath_enabled = tool_fastpath_enabled + self.tool_fastpath_allowed_tools = tool_fastpath_allowed_tools + self.minio_client = minio_client + self.conversation_id = conversation_id + self.user_id = user_id + self.tenant_id = tenant_id + self.workspace_path = workspace_path + self.workspace_mapping = None + if sandbox_config is not None and sandbox_config.workspace_mode == "bind" and workspace_path: + from nexent.core.agents.sandbox.config import SandboxLevel + + if sandbox_config.level == SandboxLevel.DOCKER: + self.workspace_mapping = sandbox_config.bind_workspace().for_run(workspace_path) + self.workspace_run_id = workspace_run_id + self.minio_files = list(minio_files or []) + self.user_context = dict(user_context or {}) + self._workspace_uploads: List[Dict[str, Any]] = [] + self._workspace_uploaded_paths: set[str] = set() + self._sandbox_executors: List[Any] = [] + self._sandbox_skill_runners: List[Any] = [] + self._tool_fastpath_executors: List[Any] = [] + self._subagent_invocations: Dict[Event, Any] = {} + self._subagent_invocations_lock = Lock() + self._subagent_invocations_closed = False + + self.agent = None + + def close(self): + """Drain children before tearing down owned workspace and executors.""" + if self._resources_closed: + return + self._close_subagent_invocations() + try: + self._finalize_file_workspace() + finally: + try: + self._cleanup_file_workspace() + finally: + self._cleanup_sandbox() + self._resources_closed = True diff --git a/sdk/nexent/core/agents/subagent_wrapper.py b/sdk/nexent/core/agents/resources/subagents.py similarity index 98% rename from sdk/nexent/core/agents/subagent_wrapper.py rename to sdk/nexent/core/agents/resources/subagents.py index 6443de36b0..170e40dcc4 100644 --- a/sdk/nexent/core/agents/subagent_wrapper.py +++ b/sdk/nexent/core/agents/resources/subagents.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Wrappers for sub-agents invoked by a managed parent. The parent agent owns a single ``MessageObserver``. When the parent's ReAct @@ -19,14 +21,14 @@ from dataclasses import replace from typing import Any, Callable, Iterable -from ...monitor import ( +from nexent.core.utils.observer import MessageObserver +from nexent.monitor import ( OPENINFERENCE_SPAN_KIND_AGENT, AgentRunMetadata, agent_monitoring_context, get_agent_monitoring_context, get_monitoring_manager, ) -from ..utils.observer import MessageObserver class SubAgentDefinition: diff --git a/sdk/nexent/core/agents/tool_parameters.py b/sdk/nexent/core/agents/resources/tool_parameters.py similarity index 94% rename from sdk/nexent/core/agents/tool_parameters.py rename to sdk/nexent/core/agents/resources/tool_parameters.py index b9f16348d2..f7010b1c16 100644 --- a/sdk/nexent/core/agents/tool_parameters.py +++ b/sdk/nexent/core/agents/resources/tool_parameters.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Apply configured MCP arguments to an isolated run's tool instance.""" import functools diff --git a/sdk/nexent/core/agents/tool_user_context.py b/sdk/nexent/core/agents/resources/tool_user_context.py similarity index 98% rename from sdk/nexent/core/agents/tool_user_context.py rename to sdk/nexent/core/agents/resources/tool_user_context.py index b8e119cb46..69ab81f376 100644 --- a/sdk/nexent/core/agents/tool_user_context.py +++ b/sdk/nexent/core/agents/resources/tool_user_context.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """User-context pass-through for agent tools. The platform itself performs no authorization for tool calls. When an MCP tool's diff --git a/sdk/nexent/core/agents/resources/tools.py b/sdk/nexent/core/agents/resources/tools.py new file mode 100644 index 0000000000..1d5dfd6dd6 --- /dev/null +++ b/sdk/nexent/core/agents/resources/tools.py @@ -0,0 +1,372 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + + +from __future__ import annotations + +import json +import logging +from copy import copy, deepcopy + +import nexent.core.tools as tool_types +from nexent.core.agents.contracts.config import ToolConfig +from nexent.core.agents.resources.compatibility.smolagents_tools import from_langchain +from nexent.core.agents.resources.tool_parameters import apply_mcp_tool_parameters +from nexent.core.agents.resources.tool_user_context import apply_user_context_to_mcp_tool + + +logger = logging.getLogger(__name__) + + +class ToolResources: + """Create authorized bindings without owning an Agent decision loop.""" + + def _resolve_tool_class(self, name): + return getattr(tool_types, name, None) + + def create_local_tool(self, tool_config: ToolConfig): + class_name = tool_config.class_name + params = tool_config.params + tool_class = self._resolve_tool_class(class_name) + if tool_class is None: + raise ValueError(f"{class_name} not found in local") + else: + if class_name == "KnowledgeBaseSearchTool": + # Filter out conflicting parameters from params to avoid conflicts. + # Parameters declared with exclude=True cannot be passed to __init__ + # due to smolagents.tools.Tool wrapper restrictions; they are set as + # attributes on the instance after construction, sourced from metadata. + # `document_paths` is intentionally hidden from the LLM and only + # populated via tool_params from the northbound interface. + filtered_params = {k: v for k, v in params.items() + if k not in ["vdb_core", "embedding_model", "observer", "rerank_model", "display_name_to_index_map"]} + # Create instance with only non-excluded parameters + tools_obj = tool_class(**filtered_params) + # Set excluded parameters directly as attributes after instantiation + # This bypasses smolagents wrapper restrictions + tools_obj.observer = self.observer + tools_obj.vdb_core = tool_config.metadata.get( + "vdb_core", None) if tool_config.metadata else None + tools_obj.embedding_model = tool_config.metadata.get( + "embedding_model", None) if tool_config.metadata else None + tools_obj.rerank_model = tool_config.metadata.get( + "rerank_model", None) if tool_config.metadata else None + tools_obj.display_name_to_index_map = tool_config.metadata.get( + "display_name_to_index_map", {}) if tool_config.metadata else {} + # Internal access control: restrict results to documents whose + # path_or_url is in the allow list. Only the northbound interface + # may populate this; never the LLM. + tools_obj.set_document_paths( + tool_config.metadata.get( + "document_paths") if tool_config.metadata else None + ) + tools_obj.set_allowed_index_names( + tool_config.metadata.get("allowed_index_names") + if tool_config.metadata else None + ) + elif class_name in ["DifySearchTool", "DataMateSearchTool"]: + # These parameters have exclude=True and cannot be passed to __init__ + filtered_params = {k: v for k, v in params.items() + if k not in ["observer", "rerank_model"]} + tools_obj = tool_class(**filtered_params) + tools_obj.observer = self.observer + tools_obj.rerank_model = tool_config.metadata.get( + "rerank_model", None) if tool_config.metadata else None + elif class_name == "RAGFlowSearchTool": + # RAGFlowSearchTool does not accept rerank/rerank_model_name as + # init params — RAGFlow handles reranking internally via its API. + # The rerank_model attribute is set post-init for display and + # observability purposes only (e.g., showing model info in the UI). + filtered_params = {k: v for k, v in params.items() + if k not in ["observer", "rerank_model", "rerank", "rerank_model_name"]} + tools_obj = tool_class(**filtered_params) + tools_obj.observer = self.observer + tools_obj.rerank_model = tool_config.metadata.get( + "rerank_model", None) if tool_config.metadata else None + elif class_name == "HaotianSearchTool": + # Haotian uses reranking_enable/reranking_model_name (not rerank/rerank_model_name) + filtered_params = {k: v for k, v in params.items() + if k not in ["observer", "rerank_model", "rerank"]} + tools_obj = tool_class(**filtered_params) + tools_obj.observer = self.observer + elif class_name == "AnalyzeTextFileTool": + # Extract validate_url_access from metadata if it's callable + validate_url_access = tool_config.metadata.get("validate_url_access") if tool_config.metadata else None + if validate_url_access is not None and not callable(validate_url_access): + validate_url_access = None + tools_obj = tool_class(observer=self.observer, + llm_model=tool_config.metadata.get("llm_model", []), + storage_client=tool_config.metadata.get("storage_client", []), + data_process_service_url=tool_config.metadata.get("data_process_service_url", []), + validate_url_access=validate_url_access, + **params) + elif class_name in ["AnalyzeImageTool", "AnalyzeAudioTool", "AnalyzeVideoTool"]: + # Extract validate_url_access from metadata if it's callable + validate_url_access = tool_config.metadata.get("validate_url_access") if tool_config.metadata else None + if validate_url_access is not None and not callable(validate_url_access): + validate_url_access = None + tools_obj = tool_class(observer=self.observer, + vlm_model=tool_config.metadata.get("vlm_model", []), + storage_client=tool_config.metadata.get("storage_client", []), + validate_url_access=validate_url_access, + **params) + elif class_name in ["StoreMemoryTool", "SearchMemoryTool"]: + metadata = tool_config.metadata or {} + tools_obj = tool_class() + if tool_config.description is not None: + tools_obj.description = tool_config.description + if tool_config.inputs is not None: + tools_obj.inputs = json.loads(tool_config.inputs) + if tool_config.output_type is not None: + tools_obj.output_type = tool_config.output_type + tools_obj.observer = self.observer + tools_obj.memory_config = metadata.get( + "memory_config", {}) if metadata else {} + tools_obj.tenant_id = metadata.get( + "tenant_id", "") if metadata else "" + tools_obj.user_id = metadata.get( + "user_id", "") if metadata else "" + tools_obj.agent_id = metadata.get( + "agent_id", "") if metadata else "" + raw_conversation_id = ( + metadata.get("conversation_id", "") if metadata else "" + ) + tools_obj.conversation_id = ( + str(raw_conversation_id) + if raw_conversation_id not in (None, "") + else "" + ) + tools_obj.memory_user_config = metadata.get( + "memory_user_config", None) if metadata else None + tools_obj.memory_service = metadata.get( + "memory_service", None) if metadata else None + tools_obj.embedding_configured = metadata.get( + "embedding_configured", True + ) if metadata else True + if class_name == "SearchMemoryTool": + tools_obj.memory_context_service = metadata.get( + "memory_context_service", None) if metadata else None + else: + tools_obj.memory_context_service = None + elif class_name == "AidpSearchTool": + # kds_name_to_id_map is exclude=True; inject via metadata after init + filtered_params = {k: v for k, v in params.items() + if k not in ["kds_name_to_id_map"]} + tools_obj = tool_class(**filtered_params) + tools_obj.observer = self.observer + tools_obj.kds_name_to_id_map = tool_config.metadata.get( + "kds_name_to_id_map", {}) if tool_config.metadata else {} + # Install the KDS whitelist so the tool only retrieves from + # KBs the current user is permitted to see. Guard against + # ``metadata=None`` the same way every other branch does + # (``.get(...) if tool_config.metadata else ...``). + allowed_raw = ( + tool_config.metadata.get("allowed_kds_set") + if tool_config.metadata else None + ) + if allowed_raw is not None: + try: + tools_obj.set_allowed_kds([str(k) for k in allowed_raw]) + except Exception as exc: + logger.warning( + "Failed to install Aidp whitelist from metadata: %s; " + "falling back to an empty whitelist", exc, + ) + tools_obj.set_allowed_kds([]) + else: + # Whitelist not set by backend → treat as uninstalled. + tools_obj.set_allowed_kds(None) + elif class_name == "IndependentAidpSearchTool": + filtered_params = { + key: value + for key, value in params.items() + if key not in ["observer", "image_url_builder", "rerank_model", "rerank"] + } + tools_obj = tool_class(**filtered_params) + tools_obj.observer = self.observer + tools_obj.image_url_builder = ( + tool_config.metadata.get("image_url_builder") + if tool_config.metadata else None + ) + elif class_name in ["DownloadFromS3Tool", "UploadToS3Tool"]: + metadata = tool_config.metadata or {} + tools_obj = tool_class( + workspace_path=params.get("workspace_path", "/mnt/nexent"), + minio_client=metadata.get("minio_client"), + user_id=metadata.get("user_id", ""), + tenant_id=metadata.get("tenant_id", ""), + observer=self.observer, + ) + else: + tools_obj = tool_class(**params) + if hasattr(tools_obj, 'observer'): + tools_obj.observer = self.observer + if tool_config.inputs and hasattr(tools_obj, "inputs"): + parsed_inputs = tool_config.inputs + if isinstance(parsed_inputs, str): + try: + parsed_inputs = json.loads(parsed_inputs) + except (TypeError, ValueError): + parsed_inputs = None + if isinstance(parsed_inputs, dict): + tools_obj.inputs = parsed_inputs + if tool_config.output_type and hasattr(tools_obj, "output_type"): + tools_obj.output_type = tool_config.output_type + return tools_obj + + + def create_langchain_tool(self, tool_config: ToolConfig): + tool_obj = tool_config.metadata + return from_langchain(tool_obj) + + + def create_mcp_tool(self, class_name): + if self.mcp_tool_collection is None: + raise ValueError("MCP tool collection is not initialized") + tool_obj = next( + (tool for tool in self.mcp_tool_collection.tools if tool.name == class_name), + None + ) + if tool_obj is None: + raise ValueError(f"{class_name} not found in MCP server") + # Collections may be shared by multiple runs; never wrap their template. + tool_obj = copy(tool_obj) + tool_obj.inputs = deepcopy(tool_obj.inputs) + return apply_user_context_to_mcp_tool(tool_obj, self.user_context) + + + def create_builtin_tool(self, tool_config: ToolConfig): + """Create a builtin tool instance. + + Args: + tool_config: Tool configuration with class_name, params, and optional metadata. + + Returns: + Tool instance + + Raises: + ValueError: If builtin tool is not found + """ + class_name = tool_config.class_name + params = tool_config.params or {} + + if class_name == "RunSkillScriptTool": + from nexent.core.tools.run_skill_script_tool import RunSkillScriptTool + metadata = tool_config.metadata or {} + kwargs = dict( + local_skills_dir=params.get("local_skills_dir"), + agent_id=metadata.get("agent_id"), + tenant_id=metadata.get("tenant_id"), + version_no=metadata.get("version_no", 0), + observer=self.observer, + authorized_skill_names=params.get("authorized_skill_names"), + ) + if params.get("isolated_skills_root"): + kwargs["isolated_skills_root"] = True + if params.get("workspace_path"): + kwargs["workspace_path"] = params["workspace_path"] + kwargs["on_complete"] = lambda _result: self._push_file_workspace_to_sandbox() + return RunSkillScriptTool(**kwargs) + elif class_name == "ReadSkillMdTool": + from nexent.core.tools.read_skill_md_tool import ReadSkillMdTool + metadata = tool_config.metadata or {} + kwargs = dict( + local_skills_dir=params.get("local_skills_dir"), + agent_id=metadata.get("agent_id"), + tenant_id=metadata.get("tenant_id"), + version_no=metadata.get("version_no", 0), + ) + if params.get("authorized_skill_names") is not None: + kwargs["authorized_skill_names"] = params["authorized_skill_names"] + if params.get("isolated_skills_root"): + kwargs["isolated_skills_root"] = True + return ReadSkillMdTool(**kwargs) + elif class_name == "ReadSkillConfigTool": + from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool + metadata = tool_config.metadata or {} + kwargs = dict( + local_skills_dir=params.get("local_skills_dir"), + agent_id=metadata.get("agent_id"), + tenant_id=metadata.get("tenant_id"), + version_no=metadata.get("version_no", 0), + config_overrides=params.get("config_overrides"), + ) + if params.get("authorized_skill_names") is not None: + kwargs["authorized_skill_names"] = params["authorized_skill_names"] + return ReadSkillConfigTool(**kwargs) + elif class_name == "DownloadFromS3Tool": + from nexent.core.tools.download_from_s3_tool import DownloadFromS3Tool + metadata = tool_config.metadata or {} + return DownloadFromS3Tool( + workspace_path=params.get("workspace_path", self.workspace_path or "/mnt/nexent/workdir"), + minio_client=metadata.get("minio_client"), + user_id=metadata.get("user_id", self.user_id or ""), + tenant_id=metadata.get("tenant_id", self.tenant_id or ""), + observer=self.observer, + validate_url_access=metadata.get("validate_url_access"), + on_download=lambda _result: self._push_file_workspace_to_sandbox(), + ) + elif class_name == "UploadToS3Tool": + from nexent.core.tools.upload_to_s3_tool import UploadToS3Tool + metadata = tool_config.metadata or {} + return UploadToS3Tool( + workspace_path=params.get("workspace_path", self.workspace_path or "/mnt/nexent/workdir"), + minio_client=metadata.get("minio_client"), + user_id=metadata.get("user_id", self.user_id or ""), + tenant_id=metadata.get("tenant_id", self.tenant_id or ""), + observer=self.observer, + run_id=metadata.get("run_id", self.workspace_run_id or ""), + on_upload=self._record_workspace_upload, + ensure_local_file=lambda _path: self._pull_file_workspace_from_sandbox(), + uploaded_paths=self._workspace_uploaded_paths, + ) + elif class_name == "CreatePlanTool": + from nexent.core.tools.plan_tools import CreatePlanTool + return CreatePlanTool() + elif class_name == "UpdatePlanStepTool": + from nexent.core.tools.plan_tools import UpdatePlanStepTool + return UpdatePlanStepTool() + elif class_name == "CreateScheduledTaskProposalTool": + from nexent.core.tools.create_scheduled_task_tool import CreateScheduledTaskProposalTool + metadata = tool_config.metadata or {} + return CreateScheduledTaskProposalTool( + create_proposal=metadata.get("create_proposal"), + observer=self.observer, + ) + else: + raise ValueError(f"Unknown builtin tool: {class_name}") + + + def create_tool(self, tool_config: ToolConfig): + """create a tool instance according to the tool config""" + if not isinstance(tool_config, ToolConfig): + raise TypeError("tool_config must be a ToolConfig object") + try: + class_name = tool_config.class_name + source = tool_config.source + + if source == "local": + tool_obj = self.create_local_tool(tool_config) + elif source == "mcp": + tool_obj = self.create_mcp_tool(class_name) + tool_obj = apply_mcp_tool_parameters(tool_obj, tool_config.params) + elif source == "langchain": + tool_obj = self.create_langchain_tool(tool_config) + elif source == "builtin": + tool_obj = self.create_builtin_tool(tool_config) + else: + raise ValueError(f"unsupported tool source: {source}") + mapping = getattr(self, "workspace_mapping", None) + if mapping is not None and class_name in { + "CreateFileTool", "ReadFileTool", "DeleteFileTool", "DownloadFromS3Tool", "UploadToS3Tool", + }: + tool_obj.workspace_mapping = mapping + if class_name in {"CreateFileTool", "ReadFileTool", "DeleteFileTool"}: + tool_obj.init_path = str(mapping.host_root / "outputs") + if source in {"local", "builtin", "mcp"}: + try: + setattr(tool_obj, "_nexent_execute_on_host", True) + except (AttributeError, TypeError): + pass + return tool_obj + except Exception as e: + raise ValueError(f"Error in creating tool: {e}") diff --git a/sdk/nexent/core/agents/resources/workspace.py b/sdk/nexent/core/agents/resources/workspace.py new file mode 100644 index 0000000000..c0d030e677 --- /dev/null +++ b/sdk/nexent/core/agents/resources/workspace.py @@ -0,0 +1,630 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Run workspace mapping, file synchronization and executor resource ownership.""" + +from __future__ import annotations + +import hashlib +import io +import json +import logging +import os +import shutil +import tarfile +from concurrent.futures import CancelledError +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Dict, List, Optional + +from nexent.core.agents.prompt.user_context import render_user_context +from nexent.core.agents.resources.compatibility.smolagents_tools import deferred_executor_type +from nexent.core.utils.observer import ProcessType + + +logger = logging.getLogger(__name__) + + +def validate_container_root(value: str) -> PurePosixPath: + """Reject host paths and traversal before constructing a container path.""" + path = PurePosixPath(value) + if not path.is_absolute() or path == PurePosixPath('/') or '\\' in value or ':' in value or '..' in path.parts: + raise ValueError('container workspace must be a non-root POSIX absolute path') + return path + + +@dataclass(frozen=True) +class SandboxWorkspace: + host_root: Path + container_root: PurePosixPath + + def __post_init__(self): + object.__setattr__(self, 'host_root', Path(self.host_root).resolve()) + object.__setattr__(self, 'container_root', validate_container_root(str(self.container_root))) + + def to_container(self, path: str | Path) -> PurePosixPath: + relative = Path(path).resolve().relative_to(self.host_root) + return self.container_root.joinpath(*relative.parts) + + def to_host(self, value: str) -> Path: + """Translate an absolute kernel path and enforce the resolved host boundary.""" + path = validate_container_root(value) + relative = path.relative_to(self.container_root) + # Reject Windows-special components, including alternate data streams. + if any(PureWindowsPath(part).is_reserved() for part in relative.parts): + raise ValueError('Reserved host filename') + target = self.host_root.joinpath(*relative.parts).resolve() + target.relative_to(self.host_root) + return target + + def resolve_file(self, value: str, base: str | Path) -> Path: + """Resolve a tool path without changing that tool's relative-path convention.""" + base_path = Path(base).resolve() + base_path.relative_to(self.host_root) + if value.startswith('/'): + target = self.to_host(value) + else: + if '..' in PureWindowsPath(value).parts: + raise ValueError('Workspace traversal is not allowed') + target = (base_path / value).resolve() + target.relative_to(base_path) + return target + + def for_run(self, host_run: str | Path) -> 'SandboxWorkspace': + return SandboxWorkspace(Path(host_run), self.to_container(host_run)) + + @property + def mount_id(self) -> str: + payload = json.dumps([os.path.normcase(str(self.host_root)), str(self.container_root)]) + return hashlib.sha256(payload.encode()).hexdigest() + + def matches_mount(self, mount: dict) -> bool: + """Compare Desktop's Linux view of drive paths with the configured source.""" + source = str(mount.get('Source', '')).replace('\\', '/') + expected = str(self.host_root).replace('\\', '/') + if self.host_root.drive: + for prefix in ('/run/desktop/mnt/host/', '/host_mnt/'): + if source.startswith(prefix): + tail = source[len(prefix):] + if len(tail) >= 2 and tail[1] == '/': + source = tail[0] + ':' + tail[1:] + break + source, expected = source.casefold(), expected.casefold() + return ( + mount.get('Type') == 'bind' and mount.get('RW') is True + and mount.get('Destination') == str(self.container_root) + and source.rstrip('/') == expected.rstrip('/') + ) + + +def probe_workspace(container, workspace: PurePosixPath) -> None: + """Verify traversal and real read/write access as the container's configured user.""" + code = ( + 'import os,tempfile; from pathlib import Path; ' + f'p=Path({str(workspace)!r}); ' + 'os.chdir(p.parent); os.chdir(p.name); ' + '[(f.open("rb").read(1)) for f in (p/"inputs").rglob("*") if f.is_file()]; ' + 't=tempfile.TemporaryDirectory(prefix=".nexent-probe-",dir=p/"outputs"); ' + 'f=Path(t.name)/"nested"/"file.txt"; f.parent.mkdir(); ' + 'f.write_text("probe",encoding="utf-8"); ' + 'assert f.read_text(encoding="utf-8")=="probe"; t.cleanup()' + ) + result = container.exec_run(['python', '-c', code]) + if result.exit_code != 0: + raise RuntimeError(f'Sandbox workspace access failed: {result.output!r}') + + +def cleanup_run_workspace( + workspace_path: str | None, + workspace_run_id: str | None, + logger_: logging.Logger | None = None, +) -> bool: + """Delete one exact run workspace and its empty user directory.""" + if not workspace_path or not workspace_run_id: + return False + + cleanup_logger = logger_ or logger + workspace = Path(workspace_path).resolve() + if workspace.name != workspace_run_id: + cleanup_logger.error( + "Refusing to clean workspace whose final component does not match run id: %s", + workspace, + ) + return False + + try: + removed = workspace.exists() + if removed: + shutil.rmtree(workspace) + try: + workspace.parent.rmdir() + except OSError: + pass + return removed + except Exception as exc: + cleanup_logger.error("Failed to clean run workspace %s: %s", workspace, exc) + return False + +_WORKSPACE_UPLOAD_EXCLUDED_DIRS = { + ".skill_snapshot", + ".cache", + ".npm", + ".parcel-cache", + ".pnpm-store", + ".venv", + "__pycache__", + "node_modules", + "venv", +} + + +class WorkspaceResources: + """Own workspace and executor cleanup for one run.""" + + def _check_sandbox_cancelled(self) -> None: + """Stop initialization and retries after the owning run is cancelled.""" + if self.stop_event.is_set() is True: + raise CancelledError("Sandbox initialization cancelled") + + + def _record_workspace_upload(self, upload: Dict[str, Any]) -> None: + """Collect one upload result for the frontend artifact event.""" + object_name = str(upload.get("object_name") or "") + if object_name and any(item.get("object_name") == object_name for item in self._workspace_uploads): + return + self._workspace_uploads.append(upload) + + + def _prepare_file_workspace(self, query: str) -> str: + """Create the run workspace and download current-request attachments.""" + if not self.workspace_path: + return query + + workspace = Path(self.workspace_path) + (workspace / "inputs").mkdir(parents=True, exist_ok=True) + (workspace / "outputs").mkdir(parents=True, exist_ok=True) + download_tool = (getattr(self.agent, "tools", {}) or {}).get("download_from_s3") + downloaded: List[Dict[str, str]] = [] + if self.minio_files and download_tool is None: + raise RuntimeError("Uploaded files are present but download_from_s3 is unavailable") + + for index, item in enumerate(self.minio_files): + if not isinstance(item, dict): + continue + object_name = str(item.get("object_name") or "").strip().lstrip("/") + source_url = str(item.get("url") or "").strip() + if object_name: + bucket = getattr(getattr(download_tool, "minio_client", None), "default_bucket", None) or "nexent" + source_url = f"s3://{bucket}/{object_name}" + if not source_url: + continue + filename = os.path.basename(str(item.get("name") or object_name or f"file_{index}")) + local_filename = f"inputs/{index:03d}_{filename}" + result = json.loads(download_tool.forward(source_url, local_filename)) + downloaded.append({"name": filename, "path": result["local_path"]}) + + language = getattr(self.observer, "lang", "en") + mapping = getattr(self, "workspace_mapping", None) + display_workspace = mapping.container_root if mapping is not None else workspace + workspace_note = render_user_context(language, "workspace_note", { + "workspace": display_workspace, + }) + self._push_file_workspace_to_sandbox() + self._initialize_sandbox_workspaces() + return query + workspace_note + + + def _sandbox_container(self) -> Any: + """Return the active Docker container when the executor exposes one.""" + containers = self._sandbox_containers() + return containers[0] if containers else None + + + def _sandbox_containers(self) -> List[Any]: + """Return every distinct Docker container used by this agent tree.""" + executors = list(self._sandbox_executors) + root_executor = getattr(self.agent, "python_executor", None) + if root_executor is not None and all( + executor is not root_executor for executor in executors + ): + executors.append(root_executor) + + containers: List[Any] = [] + seen_keys = set() + for executor in executors: + container = getattr(executor, "container", None) + if container is None: + continue + container_id = getattr(container, "id", None) + key = container_id if isinstance(container_id, str) and container_id else id(container) + if key in seen_keys: + continue + seen_keys.add(key) + containers.append(container) + return containers + + + def _uses_shared_file_workspace(self) -> bool: + """Return whether the runtime and sandbox use the same workspace volume.""" + if getattr(self, "workspace_mapping", None) is not None: + return True + extra_kwargs = getattr(self.sandbox_config, "extra_kwargs", {}) or {} + return bool( + extra_kwargs.get("shared_workspace") + and extra_kwargs.get("workspace_volume_name") + ) + + + def _push_file_workspace_to_sandbox(self, containers: Optional[List[Any]] = None) -> None: + """Copy the prepared host workspace into every Docker sandbox.""" + if containers is None: + containers = self._sandbox_containers() + if not containers or not self.workspace_path: + return + workspace = Path(self.workspace_path).resolve() + mapping = getattr(self, "workspace_mapping", None) + if mapping is not None: + self._verify_bind_workspace_access(containers, workspace, mapping) + return + if not workspace.exists() or workspace.drive: + return + shared_workspace = self._uses_shared_file_workspace() + archive_bytes = None + if not shared_workspace: + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w") as tar: + tar.add(workspace, arcname=str(workspace).lstrip("/"), recursive=True) + archive_bytes = archive.getvalue() + + for container in containers: + if archive_bytes is not None and not container.put_archive("/", archive_bytes): + raise RuntimeError("Failed to copy run workspace into the sandbox") + self._grant_sandbox_output_access(container, workspace) + + + def _verify_bind_workspace_access(self, containers, workspace: Path, mapping: SandboxWorkspace) -> None: + """Probe the mounted run directory without copying or changing Windows ACLs.""" + for container in containers: + try: + if not workspace.drive: + self._grant_sandbox_output_access(container, mapping.container_root) + probe_workspace(container, mapping.container_root) + except Exception: + logger.exception( + "Sandbox FAILED phase=workspace_access run_id=%s container_id=%s", + self.workspace_run_id, getattr(container, "id", None), + ) + raise + + + def _initialize_sandbox_workspaces(self, executors: Optional[List[Any]] = None) -> None: + """Set every Docker kernel's cwd and workspace environment for this run.""" + if not self.workspace_path: + return + workspace = Path(self.workspace_path).resolve() + mapping = getattr(self, "workspace_mapping", None) + if mapping is not None: + workspace = mapping.container_root + output_dir = workspace / "outputs" + bootstrap_code = ( + "import os as _nexent_os\n" + f"_nexent_workspace = {json.dumps(str(workspace))}\n" + f"_nexent_output_dir = {json.dumps(str(output_dir))}\n" + "_nexent_os.environ['NEXENT_WORKSPACE'] = _nexent_workspace\n" + "_nexent_os.environ['NEXENT_OUTPUT_DIR'] = _nexent_output_dir\n" + "_nexent_os.chdir(_nexent_output_dir)\n" + "[_nexent_workspace, _nexent_output_dir]" + ) + seen_executor_ids = set() + for executor in self._sandbox_executors if executors is None else executors: + self._check_sandbox_cancelled() + executor_id = id(executor) + if executor_id in seen_executor_ids: + continue + seen_executor_ids.add(executor_id) + backend = getattr(executor, "_nexent_backend", None) + if backend == "local": + continue + if backend != "docker" and getattr(executor, "container", None) is None: + continue + register_bootstrap = None + if ( + getattr(executor, "_nexent_kernel_recovery_supported", False) + is True + and callable( + getattr(type(executor), "register_kernel_bootstrap_code", None) + ) + ): + register_bootstrap = executor.register_kernel_bootstrap_code + execute_bootstrap = ( + register_bootstrap if callable(register_bootstrap) else executor + ) + try: + execute_bootstrap(bootstrap_code) + self._check_sandbox_cancelled() + logger.info( + "Sandbox READY run_id=%s actual_backend=docker scope=%s container_id=%s kernel_id=%s " + "host_workspace=%s container_workspace=%s", + self.workspace_run_id, getattr(getattr(self.sandbox_config, "scope", None), "value", None), + getattr(getattr(executor, "container", None), "id", None), + getattr(executor, "kernel_id", None), self.workspace_path, workspace, + ) + except CancelledError: + raise + except Exception as exc: + self._check_sandbox_cancelled() + # Workspace initialization is idempotent. If the kernel channel + # failed and marked this lease unhealthy, retry the bootstrap in + # the same run so the lease can replace its kernel immediately. + # Do not apply this retry to arbitrary generated code because a + # lost terminal message does not prove that code had no effects. + if ( + getattr(executor, "_nexent_kernel_recovery_supported", False) + and getattr(executor, "_unhealthy", False) + # Registered bootstraps already own their bounded recovery. + and not callable(register_bootstrap) + ): + logger.warning( + "Retrying sandbox workspace initialization with a replacement kernel: %s", + exc, + exc_info=True, + ) + try: + execute_bootstrap(bootstrap_code) + self._check_sandbox_cancelled() + continue + except CancelledError: + raise + except Exception as retry_exc: + self._check_sandbox_cancelled() + exc = retry_exc + logger.exception( + "Sandbox FAILED phase=workspace run_id=%s: %s", self.workspace_run_id, exc, + exc_info=(type(exc), exc, exc.__traceback__), + ) + raise RuntimeError( + f"Failed to initialize sandbox workspace '{workspace}': {exc}" + ) from exc + + + @staticmethod + def _grant_sandbox_output_access(container: Any, workspace: Path) -> None: + """Allow sandbox traversal of the user directory and writes in the run workspace.""" + gid_result = container.exec_run(["id", "-g"]) + gid_exit_code = getattr(gid_result, "exit_code", None) + gid_output = getattr(gid_result, "output", b"") + if gid_exit_code != 0: + raise RuntimeError("Failed to determine the sandbox user's group") + + if isinstance(gid_output, bytes): + gid_output = gid_output.decode("utf-8", errors="replace") + sandbox_gid = str(gid_output).strip() + if not sandbox_gid.isdigit(): + raise RuntimeError("Sandbox user returned an invalid group ID") + + workspace_dir = str(workspace) + workspace_parent_dir = str(workspace.parent) + commands = ( + ["chgrp", sandbox_gid, workspace_parent_dir], + ["chmod", "g+xs", workspace_parent_dir], + ["chgrp", "-R", sandbox_gid, workspace_dir], + ["chmod", "-R", "g+rwX", workspace_dir], + ["find", workspace_dir, "-type", "d", "-exec", "chmod", "g+s", "{}", "+"], + ) + for command in commands: + result = container.exec_run(command, user="0") + if getattr(result, "exit_code", None) != 0: + output = getattr(result, "output", b"") + if isinstance(output, bytes): + output = output.decode("utf-8", errors="replace") + raise RuntimeError( + f"Failed to grant sandbox output access: {str(output).strip()}" + ) + + + def _pull_file_workspace_from_sandbox(self) -> None: + """Copy outputs from every Docker sandbox back to the host workspace.""" + containers = self._sandbox_containers() + if not containers or not self.workspace_path: + return + if self._uses_shared_file_workspace(): + return + workspace = Path(self.workspace_path).resolve() + if workspace.drive: + return + for container in containers: + try: + chunks, _ = container.get_archive(str(workspace)) + archive = io.BytesIO(b"".join(chunks)) + with tarfile.open(fileobj=archive, mode="r:*") as tar: + members = [] + extraction_root = workspace.parent.resolve() + for member in tar.getmembers(): + if not (member.isfile() or member.isdir()): + raise RuntimeError("Sandbox workspace archive contains an unsupported entry") + target = (extraction_root / member.name).resolve() + try: + target.relative_to(extraction_root) + except ValueError as exc: + raise RuntimeError("Sandbox workspace archive escapes the run root") from exc + members.append(member) + tar.extractall(extraction_root, members=members) + except Exception as exc: + logger.warning("Failed to copy sandbox workspace back to host: %s", exc) + + + def _finalize_file_workspace(self) -> None: + """Upload all workspace outputs that were not already uploaded explicitly.""" + if not self.workspace_path: + return + self._pull_file_workspace_from_sandbox() + workspace = Path(self.workspace_path) + upload_tool = (getattr(self.agent, "tools", {}) or {}).get("upload_to_s3") + if upload_tool is None: + logger.error("Run workspace exists but upload_to_s3 is unavailable") + return + + uploaded_paths = getattr(upload_tool, "uploaded_paths", set()) + for path in workspace.rglob("*") if workspace.exists() else (): + if not path.is_file(): + continue + relative = path.relative_to(workspace) + if relative.parts and relative.parts[0] in {"inputs", "skills"}: + continue + if any(part in _WORKSPACE_UPLOAD_EXCLUDED_DIRS for part in relative.parts[:-1]): + continue + normalized = os.path.normcase(os.path.abspath(str(path))) + if normalized in uploaded_paths: + continue + try: + upload_tool.forward(str(path), relative.as_posix()) + except Exception as exc: + logger.error("Failed to upload workspace output %s: %s", path, exc) + self.observer.add_message( + "", + ProcessType.WARNING, + f"Failed to upload output file {relative}: {exc}", + ) + + if self._workspace_uploads: + self.observer.add_message( + "", + ProcessType.FILE_ARTIFACT, + {"artifacts": list(self._workspace_uploads)}, + ) + + + def _cleanup_file_workspace(self) -> None: + """Delete only the exact run-scoped workspace after upload finalization.""" + if not self.workspace_path or not self.workspace_run_id: + return + workspace = Path(self.workspace_path).resolve() + if workspace.name != self.workspace_run_id: + return + try: + containers = [] if getattr(self, "workspace_mapping", None) is not None else self._sandbox_containers() + for container in containers: + try: + result = container.exec_run( + ["rm", "-rf", "--", str(workspace)], + user="0", + ) + if getattr(result, "exit_code", None) != 0: + output = getattr(result, "output", b"") + if isinstance(output, bytes): + output = output.decode("utf-8", errors="replace") + logger.warning( + "Failed to clean sandbox run workspace %s: %s", + workspace, + str(output).strip(), + ) + except Exception as exc: + logger.warning("Failed to clean sandbox run workspace %s: %s", workspace, exc) + cleanup_run_workspace( + self.workspace_path, + self.workspace_run_id, + logger, + ) + except Exception as exc: + logger.error("Failed to clean run workspace %s: %s", workspace, exc) + + + def _release_deferred_executor(self, executor: Any) -> None: + """Release a deferred executor through its original lifecycle owner.""" + self._sandbox_executors[:] = [item for item in self._sandbox_executors if item is not executor] + if getattr(self, "_sandbox_scope", None) == "system": + from nexent.core.agents.sandbox.execution import release_python_executor + + release_python_executor(executor, logger) + else: + from nexent.core.agents.sandbox.execution import cleanup_executor + + cleanup_executor(executor, logger, timeout=5.0) + + + def _cleanup_sandbox(self) -> None: + """ + Clean up the sandbox executor after an agent run. + + For ``scope=session``: the executor is immediately destroyed. + For ``scope=system``: the executor is returned to the warm pool for reuse. + + Must run AFTER any output-sync logic, because the container filesystem + is inaccessible after the executor is released / destroyed. + """ + + DeferredToolExecutor = deferred_executor_type() + wrappers = list(self._tool_fastpath_executors) + root_executor = getattr(self.agent, "python_executor", None) + executors = list(self._sandbox_executors) + if root_executor is not None and not isinstance(root_executor, DeferredToolExecutor) and all( + item is not root_executor for item in executors + ): + executors.append(root_executor) + if not executors and not wrappers: + return + + scope = getattr(self, "_sandbox_scope", None) + + skill_owner = getattr(self, "_sandbox_skill_cleanup_owner", self) + if skill_owner is self: + for runner in self._sandbox_skill_runners: + runner.cleanup() + else: + # Shared skills remain available until the root drains every child. + with skill_owner._subagent_invocations_lock: + skill_owner._sandbox_skill_runners.extend(self._sandbox_skill_runners) + self._sandbox_skill_runners.clear() + + # Sync outputs to MinIO before destroying the container. + if ( + executors + and not self.workspace_path + and self.sandbox_config is not None + and self.sandbox_config.auto_sync_outputs + and self.minio_client is not None + ): + from nexent.core.agents.sandbox.execution import _sync_outputs_to_minio + agent_run_id = getattr(self.agent, "agent_run_id", None) or "unknown" + try: + uploaded = _sync_outputs_to_minio( + output_dir=self.sandbox_config.output_dir, + agent_run_id=agent_run_id, + minio_client=self.minio_client, + bucket="nexent-artifacts", + logger_=logger, + ) + if uploaded: + logger.info( + "Synced %d output file(s) to MinIO for run %s", + len(uploaded), + agent_run_id, + ) + except Exception as exc: + logger.error("Output sync to MinIO failed: %s", exc) + + # Release or destroy the executor. + wrapped_executor_ids = {id(wrapper.remote) for wrapper in wrappers if wrapper.remote is not None} + seen_executor_ids = set() + for executor in reversed(executors): + executor_id = id(executor) + if executor_id in seen_executor_ids or executor_id in wrapped_executor_ids: + continue + seen_executor_ids.add(executor_id) + if scope == "system": + # Return every kernel lease to the shared system sandbox. + from nexent.core.agents.sandbox.execution import release_python_executor + release_python_executor(executor, logger) + else: + # Session kernel leases are released independently; their + # agent-tree container is deleted after the final lease closes. + from nexent.core.agents.sandbox.execution import cleanup_executor + cleanup_executor(executor, logger, timeout=5.0) + + for wrapper in reversed(wrappers): + wrapper.cleanup() + self._tool_fastpath_executors.clear() + + # Clear the reference so GC can collect the wrapper objects. + if self.agent is not None: + self.agent.python_executor = None + self._sandbox_executors.clear() diff --git a/sdk/nexent/core/agents/run_agent.py b/sdk/nexent/core/agents/run_agent.py deleted file mode 100644 index 81322ee805..0000000000 --- a/sdk/nexent/core/agents/run_agent.py +++ /dev/null @@ -1,590 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -import asyncio -import json -import logging -import math -import threading -from concurrent.futures import CancelledError -from contextvars import Context, copy_context -from copy import deepcopy -from dataclasses import replace -from typing import Any, Dict, Union - -import httpx - -from ...consts.mcp_errors import ( - is_mcp_connection_timeout_error, - is_mcp_timeout_error, -) -from ...monitor import ( - AgentRunMetadata, - get_agent_monitoring_context, - get_monitoring_manager, - set_monitoring_capacity_snapshot, - set_monitoring_context_budget_snapshot, -) -from ..concurrency import ( - ManagedExecution, - ManagedTaskSpec, - RunCancellationScope, - ThreadManager, - get_current_thread_manager, -) -from ..concurrency.cancellation import RunTerminated -from ..concurrency.context import _reset_current_thread_manager, _set_current_thread_manager -from ..concurrency.helpers import ( - get_fallback_thread_manager, - shutdown_fallback_thread_manager, -) -from ..model_errors import ModelInvocationTerminalError -from .agent_model import AgentRunInfo -from .managed_mcp import ManagedMCPToolCollection -from .nexent_agent import NexentAgent, ProcessType, cleanup_run_workspace -from .output_protocol import ModelOutputProtocolExhaustedError - - -logger = logging.getLogger("run_agent") - - -def _resolve_mcp_request_timeout_seconds(agent_run_info: AgentRunInfo) -> float: - """Return a positive finite timeout for one MCP tool request.""" - timeout = getattr(agent_run_info, "mcp_request_timeout_seconds", None) - try: - timeout = float(timeout) - except (TypeError, ValueError): - timeout = 10.0 - if not math.isfinite(timeout) or timeout <= 0: - return 10.0 - return timeout - - -def _mcp_timeout_message(agent_run_info: AgentRunInfo) -> str: - """Return a localized MCP tool-execution timeout message.""" - timeout_label = f"{agent_run_info.mcp_tool_timeout_seconds:g}" - if getattr(agent_run_info.observer, "lang", "en") == "zh": - return f"MCP 工具调用超时({timeout_label} 秒)。请确认服务响应状态后重试。" - return ( - f"MCP tool execution timed out after {timeout_label} seconds. " - "Please check the service response and try again." - ) - - -def _mcp_connection_timeout_message(agent_run_info: AgentRunInfo) -> str: - """Return a localized MCP connection-timeout message for the current run.""" - timeout_label = f"{_resolve_mcp_request_timeout_seconds(agent_run_info):g}" - if getattr(agent_run_info.observer, "lang", "en") == "zh": - return f"MCP 服务连接超时({timeout_label} 秒)。请确认服务地址和网络连通性后重试。" - return ( - f"MCP connection timed out after {timeout_label} seconds. " - "Please check the service address and network connectivity, then try again." - ) - - -def _is_mcp_timeout_error(error: BaseException) -> bool: - """Identify an MCP timeout, including errors wrapped by the Agent runtime.""" - return is_mcp_timeout_error(error) - - -def _agent_run_error_message( - agent_run_info: AgentRunInfo, - mcp_host: list | None, - error: BaseException, -) -> str: - """Build the user-facing message for a failed Agent run.""" - if mcp_host and is_mcp_connection_timeout_error(error): - return _mcp_connection_timeout_message(agent_run_info) - if mcp_host and is_mcp_timeout_error(error): - return _mcp_timeout_message(agent_run_info) - if "Couldn't connect to the MCP server" in str(error): - return ( - "MCP服务器连接超时。" - if agent_run_info.observer.lang == "zh" - else "Couldn't connect to the MCP server." - ) - return f"Run Agent Error: {error}" - - -class DeferredAgentRun: - """Managed worker target that waits for request preparation to bind run data.""" - - def __init__(self): - self._ready = threading.Event() - self._lock = threading.Lock() - self._agent_run_info: AgentRunInfo | None = None - self._context: Context | None = None - self._cancelled = False - - def bind(self, agent_run_info: AgentRunInfo) -> None: - with self._lock: - if self._agent_run_info is not None: - raise RuntimeError("Deferred agent run is already bound") - self._agent_run_info = agent_run_info - self._context = copy_context() - cancelled = self._cancelled - self._ready.set() - if cancelled: - agent_run_info.cancellation_scope.cancel() - - def cancel(self) -> None: - with self._lock: - self._cancelled = True - agent_run_info = self._agent_run_info - self._ready.set() - if agent_run_info is not None: - agent_run_info.cancellation_scope.cancel() - - def run(self, cancel_event: threading.Event) -> None: - while not self._ready.wait(0.05): - if cancel_event.is_set(): - self.cancel() - return - with self._lock: - agent_run_info = self._agent_run_info - context = self._context - self._context = None - cancelled = self._cancelled or cancel_event.is_set() - if agent_run_info is None: - return - if cancelled: - agent_run_info.cancellation_scope.cancel() - return - # Admission precedes request preparation. Use the binding-time trace - # and metadata without shadowing the worker's managed execution owner. - manager = get_current_thread_manager() - - def run_bound(): - token = _set_current_thread_manager(manager) - try: - agent_run_thread(agent_run_info) - finally: - _reset_current_thread_manager(token) - - if context is not None: - context.run(run_bound) - - -def _get_default_agent_thread_manager() -> ThreadManager: - """Return a bounded fallback for direct SDK callers. - - Backend services must inject their process-local manager. The fallback keeps - the public SDK call compatible and can be closed explicitly with - ``shutdown_default_agent_thread_manager``. - """ - return get_fallback_thread_manager() - - -async def shutdown_default_agent_thread_manager(timeout: float = 15.0): - """Close the direct-SDK fallback manager when an embedding process exits.""" - return await shutdown_fallback_thread_manager(timeout=timeout) - - -def build_run_additional_args(agent_run_info: AgentRunInfo) -> Dict[str, Any]: - """Build isolated variables for one agent run, including empty metadata.""" - - return {"metadata": deepcopy(agent_run_info.runtime_metadata)} - - -def _get_authorized_context_items(agent_run_info: AgentRunInfo): - """Return the run snapshot, falling back for direct SDK callers.""" - context_input = getattr(agent_run_info, "context_input", None) - if context_input is not None: - return tuple(context_input.items) - return getattr(agent_run_info.agent_config, "context_items", None) - - -def _get_authorized_history(agent_run_info: AgentRunInfo): - """Return the run snapshot, falling back for direct SDK callers.""" - context_input = getattr(agent_run_info, "context_input", None) - if context_input is not None: - # Historical runs are ContextItems. AgentMemory is reserved for this run. - return [] - return agent_run_info.history - - -def _log_memory_value_assessment(agent: Any) -> None: - """Emit one content-free memory assessment summary for each agent run.""" - tools = getattr(agent, "tools", {}) or {} - store_tool = tools.get("store_memory") if hasattr(tools, "get") else None - if store_tool is None: - logger.info( - "event=memory_value_assessment decision=unavailable " - "invocation_count=0 successful_store_count=0 last_outcome=tool_unavailable" - ) - return - - invocation_count = int(getattr(store_tool, "invocation_count", 0) or 0) - successful_store_count = int(getattr(store_tool, "successful_store_count", 0) or 0) - decision = "store_attempted" if invocation_count else "skip" - logger.info( - "event=memory_value_assessment tenant_id=%s user_id=%s agent_id=%s " - "conversation_id=%s decision=%s invocation_count=%d " - "successful_store_count=%d last_outcome=%s", - getattr(store_tool, "tenant_id", ""), - getattr(store_tool, "user_id", ""), - getattr(store_tool, "agent_id", ""), - getattr(store_tool, "conversation_id", ""), - decision, - invocation_count, - successful_store_count, - getattr(store_tool, "last_outcome", "not_invoked"), - ) - - -def _emit_uncertainty_reserve_warning(agent_run_info: AgentRunInfo) -> None: - snapshot = getattr(agent_run_info, "context_budget_snapshot", None) - if snapshot is None: - return - warnings = snapshot.warnings - if "uncertainty_reserve_active" not in warnings: - return - - payload = { - "code": "uncertainty_reserve_active", - "message": ( - "W2 applied the unified 10% uncertainty reserve because selected " - "model capability behavior is not fully verified." - ), - "budget_fingerprint": snapshot.fingerprint, - "w1_fingerprint": snapshot.w1_fingerprint, - "uncertainty_reserve_tokens": snapshot.uncertainty_reserve_tokens, - "effective_input_limit_tokens": snapshot.effective_input_limit_tokens, - } - logger.warning( - "W2 uncertainty reserve active: budget_fingerprint=%s w1_fingerprint=%s " - "uncertainty_reserve_tokens=%s effective_input_limit_tokens=%s", - payload["budget_fingerprint"], - payload["w1_fingerprint"], - payload["uncertainty_reserve_tokens"], - payload["effective_input_limit_tokens"], - ) - try: - agent_run_info.observer.add_message( - "", - ProcessType.OTHER, - json.dumps(payload, ensure_ascii=False), - ) - except Exception: - logger.debug("Failed to emit W2 uncertainty reserve observer warning", exc_info=True) - - -def _detect_transport(url: str) -> str: - """ - Auto-detect MCP transport type based on URL format. - - Args: - url: MCP server URL - - Returns: - Transport type: 'sse' or 'streamable-http' - """ - url_stripped = url.strip() - - if url_stripped.endswith("/sse"): - return "sse" - elif url_stripped.endswith("/mcp"): - return "streamable-http" - - return "streamable-http" - - -def _create_mcp_http_client_without_proxy( - headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: - """Create an MCP HTTP client that ignores proxy environment variables.""" - return httpx.AsyncClient( - headers=headers, - timeout=timeout, - auth=auth, - follow_redirects=True, - trust_env=False, - ) - - -def _normalize_mcp_config(mcp_host_item: Union[str, Dict[str, Any]]) -> Dict[str, Any]: - """ - Normalize MCP host configuration to a dictionary format. - - Args: - mcp_host_item: Either a string URL or a dict with 'url', optional 'transport', - 'headers', 'authorization', or 'httpx_client_factory' - - Returns: - Dictionary with 'url', 'transport', and supported transport options - """ - if isinstance(mcp_host_item, str): - url = mcp_host_item - transport = _detect_transport(url) - return {"url": url, "transport": transport} - elif isinstance(mcp_host_item, dict): - url = mcp_host_item.get("url") - if not url: - raise ValueError("MCP host dict must contain 'url' key") - transport = mcp_host_item.get("transport") - if not transport: - transport = _detect_transport(url) - if transport not in ("sse", "streamable-http"): - raise ValueError(f"Invalid transport type: {transport}. Must be 'sse' or 'streamable-http'") - - result = {"url": url, "transport": transport} - - if mcp_host_item.get("bypass_proxy") is True: - result["httpx_client_factory"] = _create_mcp_http_client_without_proxy - - if "authorization" in mcp_host_item and "headers" in mcp_host_item: - headers = mcp_host_item["headers"].copy() if isinstance(mcp_host_item["headers"], dict) else {} - headers["Authorization"] = mcp_host_item["authorization"] - result["headers"] = headers - elif "authorization" in mcp_host_item: - result["headers"] = {"Authorization": mcp_host_item["authorization"]} - elif "headers" in mcp_host_item: - result["headers"] = mcp_host_item["headers"] - - if "httpx_client_factory" in mcp_host_item: - httpx_client_factory = mcp_host_item["httpx_client_factory"] - if not callable(httpx_client_factory): - raise ValueError("httpx_client_factory must be callable") - result["httpx_client_factory"] = httpx_client_factory - - return result - else: - raise ValueError(f"Invalid MCP host item type: {type(mcp_host_item)}. Must be str or dict") - - -def agent_run_thread(agent_run_info: AgentRunInfo): - """Trace the complete SDK worker, including setup and resource cleanup.""" - current = get_agent_monitoring_context() or AgentRunMetadata() - metadata = replace( - current, - agent_name=current.agent_name or getattr(agent_run_info.agent_config, "name", None), - agent_display_name=current.agent_display_name or getattr(agent_run_info.agent_config, "display_name", None), - query=current.query if current.query is not None else agent_run_info.query, - ) - with get_monitoring_manager().start_agent_run(metadata): - _agent_run_thread(agent_run_info) - - -def _agent_run_thread(agent_run_info: AgentRunInfo): - mcp_host = getattr(agent_run_info, "mcp_host", None) - if hasattr(agent_run_info, "run_result"): - agent_run_info.run_result = None - try: - # Keep the runner compatible with legacy/lightweight AgentRunInfo - # stand-ins used by integrations. Full AgentRunInfo instances always - # define this field, but its absence must not prevent a run that does - # not need tool-side authorization context. - set_monitoring_capacity_snapshot( - getattr(agent_run_info, "capacity_snapshot", None) - ) - set_monitoring_context_budget_snapshot( - getattr(agent_run_info, "context_budget_snapshot", None) - ) - _emit_uncertainty_reserve_warning(agent_run_info) - mcp_host = agent_run_info.mcp_host - from .core_adapters import AgentCoreFactory - - adapter = AgentCoreFactory.create(agent_run_info, smolagents_executor=_run_smolagents) - try: - adapter.validate() - adapter.prepare() - adapter.run() - finally: - adapter.close() - - from ..agent_runtime_support.output import RunResult - - result = getattr(agent_run_info, "run_result", None) - agent_run_info.attempt_outcome = result.status if isinstance(result, RunResult) else ( - "stopped" if agent_run_info.stop_event.is_set() else "completed") - except (RunTerminated, CancelledError): - agent_run_info.attempt_outcome = "stopped" - except (ModelInvocationTerminalError, ModelOutputProtocolExhaustedError): - agent_run_info.attempt_outcome = "failed" - raise - except Exception as e: - agent_run_info.attempt_outcome = "failed" - if getattr(agent_run_info, "run_result", None) is None: - agent_run_info.observer.add_message( - "", ProcessType.FINAL_ANSWER, _agent_run_error_message(agent_run_info, mcp_host, e), - ) - raise ValueError(f"Error in agent_run_thread: {e}") - finally: - # Agent construction, MCP setup, and executor initialization can fail - # before NexentAgent.agent_run_with_observer() enters its own cleanup - # block. This idempotent outer guard owns the complete worker lifetime. - cleanup_run_workspace( - getattr(agent_run_info, "workspace_path", None), - getattr(agent_run_info, "workspace_run_id", None), - logger, - ) - - -def _run_smolagents(agent_run_info: AgentRunInfo): - """Execute the preserved smolagents resource and observer path.""" - user_context = getattr(agent_run_info, "user_context", None) - mcp_host = agent_run_info.mcp_host - if mcp_host is None or len(mcp_host) == 0: - nexent = NexentAgent( - observer=agent_run_info.observer, - model_config_list=agent_run_info.model_config_list, - stop_event=agent_run_info.stop_event, - redis_client=agent_run_info.redis_client, - user_context=user_context, - sandbox_config=getattr(agent_run_info, "sandbox_config", None), - tool_fastpath_enabled=getattr(agent_run_info, "tool_fastpath_enabled", True), - tool_fastpath_allowed_tools=getattr(agent_run_info, "tool_fastpath_allowed_tools", None), - minio_client=getattr(agent_run_info, "minio_client", None), - conversation_id=agent_run_info.conversation_id, - user_id=agent_run_info.user_id, - tenant_id=getattr(agent_run_info, "tenant_id", None), - workspace_path=getattr(agent_run_info, "workspace_path", None), - workspace_run_id=getattr(agent_run_info, "workspace_run_id", None), - minio_files=getattr(agent_run_info, "minio_files", None), - cancellation_scope=agent_run_info.cancellation_scope, - ) - nexent.core_run_info = agent_run_info - agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. - agent_run_info.agent_config, - context_items_override=_get_authorized_context_items(agent_run_info), - ) - nexent.set_agent(agent) - - nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) - try: - return nexent.agent_run_with_observer( - query=agent_run_info.query, - reset=False, - additional_args=build_run_additional_args(agent_run_info), - ) - finally: - _log_memory_value_assessment(agent) - else: - agent_run_info.observer.add_message("", ProcessType.AGENT_NEW_RUN, "") - mcp_client_list = [_normalize_mcp_config(item) for item in mcp_host] - mcp_cancellation_scope = agent_run_info.cancellation_scope or RunCancellationScope( - agent_run_info.stop_event - ) - - with ManagedMCPToolCollection( - manager=agent_run_info.thread_manager or _get_default_agent_thread_manager(), - server_parameters=mcp_client_list, - cancellation_scope=mcp_cancellation_scope, - tool_timeout_seconds=agent_run_info.mcp_tool_timeout_seconds, - close_timeout_seconds=agent_run_info.mcp_close_timeout_seconds, - connect_timeout_seconds=_resolve_mcp_request_timeout_seconds(agent_run_info), - ) as tool_collection: - nexent = NexentAgent( - observer=agent_run_info.observer, - model_config_list=agent_run_info.model_config_list, - stop_event=agent_run_info.stop_event, - mcp_tool_collection=tool_collection, - redis_client=agent_run_info.redis_client, - user_context=user_context, - sandbox_config=getattr(agent_run_info, "sandbox_config", None), - tool_fastpath_enabled=getattr(agent_run_info, "tool_fastpath_enabled", True), - tool_fastpath_allowed_tools=getattr(agent_run_info, "tool_fastpath_allowed_tools", None), - minio_client=getattr(agent_run_info, "minio_client", None), - conversation_id=agent_run_info.conversation_id, - user_id=agent_run_info.user_id, - tenant_id=getattr(agent_run_info, "tenant_id", None), - workspace_path=getattr(agent_run_info, "workspace_path", None), - workspace_run_id=getattr(agent_run_info, "workspace_run_id", None), - minio_files=getattr(agent_run_info, "minio_files", None), - cancellation_scope=agent_run_info.cancellation_scope, - ) - nexent.core_run_info = agent_run_info - agent = nexent.create_single_agent( # NOSONAR - constructs the SDK's trusted CoreAgent implementation. - agent_run_info.agent_config, - context_items_override=_get_authorized_context_items(agent_run_info), - ) - nexent.set_agent(agent) - - nexent.add_history_to_agent(_get_authorized_history(agent_run_info)) - try: - return nexent.agent_run_with_observer( - query=agent_run_info.query, - reset=False, - additional_args=build_run_additional_args(agent_run_info), - ) - finally: - _log_memory_value_assessment(agent) - - - - -async def agent_run( - agent_run_info: AgentRunInfo, - thread_manager: ThreadManager | None = None, - execution: ManagedExecution | None = None, - deferred_run: DeferredAgentRun | None = None, -): - observer = agent_run_info.observer - manager = thread_manager or _get_default_agent_thread_manager() - run_id = getattr(agent_run_info, "workspace_run_id", None) - if run_id is None and getattr(agent_run_info, "conversation_id", None) is not None: - run_id = str(agent_run_info.conversation_id) - runtime_metadata = getattr(agent_run_info, "runtime_metadata", {}) or {} - if agent_run_info.cancellation_scope is None: - agent_run_info.cancellation_scope = RunCancellationScope(agent_run_info.stop_event) - agent_run_info.thread_manager = manager - if execution is None: - execution = manager.submit( - "agent-run", - ManagedTaskSpec( - task_name="agent-run", - owner="nexent.core.agents.run_agent", - run_id=run_id, - attempt_id=runtime_metadata.get("attempt_id"), - close_hook=agent_run_info.cancellation_scope.cancel, - ), - agent_run_thread, - agent_run_info, - ) - elif deferred_run is None: - raise ValueError("deferred_run is required with a pre-admitted execution") - else: - deferred_run.bind(agent_run_info) - agent_run_info.thread_execution_id = execution.execution_id - agent_run_info.thread_future = execution.future - - worker_finished = False - try: - while not execution.future.done(): - if agent_run_info.stop_event.is_set(): - # Stop consuming the old turn even while provider I/O is blocked. - # The finally block cancels its managed worker and close hooks. - raise asyncio.CancelledError - cached_message = observer.get_cached_message() - for message in cached_message: - yield message - if len(cached_message) < 8: - await asyncio.sleep(0.05) - await asyncio.sleep(0.1) - worker_finished = True - - # Consume the exception so the Future does not emit an unobserved failure. - # agent_run_thread has already converted it into an observer error message. - try: - execution.future.result() - except Exception: - logger.debug( - "event=agent_managed_execution_failed execution_id=%s run_id=%s", - execution.execution_id, - run_id or "", - exc_info=True, - ) - - cached_message = observer.get_cached_message() - for message in cached_message: - yield message - finally: - if not worker_finished and not execution.future.done(): - agent_run_info.stop_event.set() - manager.cancel( - execution.execution_id, - reason="agent stream consumer closed", - wait_timeout=0, - mark_stuck_on_timeout=False, - ) diff --git a/sdk/nexent/core/agents/sandbox/__init__.py b/sdk/nexent/core/agents/sandbox/__init__.py new file mode 100644 index 0000000000..8422a9b134 --- /dev/null +++ b/sdk/nexent/core/agents/sandbox/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Sandbox configuration, execution and TLS submodules.""" diff --git a/sdk/nexent/core/agents/sandbox/compatibility.py b/sdk/nexent/core/agents/sandbox/compatibility.py new file mode 100644 index 0000000000..6beee2f4e9 --- /dev/null +++ b/sdk/nexent/core/agents/sandbox/compatibility.py @@ -0,0 +1,100 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit optional smolagents execution types used by the shared sandbox.""" + +import logging +from enum import IntEnum +from importlib import import_module +from types import SimpleNamespace +from typing import Any + + +class _LogLevel(IntEnum): + """Minimal LogLevel enum compatible with smolagents.monitoring.LogLevel.""" + + OFF = -1 + ERROR = 0 + INFO = 1 + DEBUG = 2 + + +class _AgentLoggerAdapter: + """ + Thin adapter that satisfies ``smolagents.AgentLogger``'s ``.log(*args, level=...)`` + call signature while routing output through the standard ``logging.Logger``. + + ``DockerExecutor`` (and other remote executors) call:: + + self.logger.log("message", level=LogLevel.INFO) + + Standard ``logging.Logger`` uses:: + + logger.log(level, "message") # positional: (int, str) + + This adapter bridges the two by accepting the smolagents signature and + forwarding to the underlying logger with the correct argument order. + """ + + def __init__(self, delegate: logging.Logger) -> None: + self._delegate = delegate + self._level_map = { + _LogLevel.OFF: logging.CRITICAL + 1, + _LogLevel.ERROR: logging.ERROR, + _LogLevel.INFO: logging.INFO, + _LogLevel.DEBUG: logging.DEBUG, + } + + def log(self, *args: Any, level: int | str | _LogLevel = _LogLevel.INFO, **kwargs: Any) -> None: + """smolagents-compatible log(): first positional arg is the message.""" + if isinstance(level, str): + level = _LogLevel[level.upper()] + numeric = self._level_map.get(_LogLevel(int(level)), logging.INFO) + if self._delegate.isEnabledFor(numeric): + # ``*args`` contains the message(s) from smolagents AgentLogger; + # ``self._delegate.log`` expects (level, msg) — swap argument order. + self._delegate.log(numeric, " ".join(str(a) for a in args), **kwargs) + + def log_error(self, message: str) -> None: + self._delegate.error(message) + + +def _make_smolagents_logger(logger_: logging.Logger) -> _AgentLoggerAdapter: + """Wrap a standard Logger into an AgentLogger-compatible adapter.""" + return _AgentLoggerAdapter(logger_) + + +def _make_code_output(logs: str) -> Any: + """Return the executor result shape expected by the agent runtime.""" + try: + from smolagents.remote_executors import CodeOutput + + return CodeOutput(output=None, logs=logs, is_final_answer=False) + except ImportError: + return SimpleNamespace(output=None, logs=logs, is_final_answer=False) + + +def _make_local_executor(additional_imports: list[str]) -> Any: + """Build a LocalPythonExecutor with the standard safe-import list.""" + from smolagents.local_python_executor import LocalPythonExecutor + + executor = LocalPythonExecutor(additional_imports) + executor._nexent_backend = "local" + return executor + + +_REMOTE_TYPES = { + "DockerExecutor", + "CodeOutput", + "_websocket_send_execute_request", + "WasmExecutor", + "AgentError", + "RemotePythonExecutor", +} + + +def __getattr__(name): + if name in _REMOTE_TYPES: + return getattr(import_module("smolagents.remote_executors"), name) + if name in {"AgentAudio", "AgentImage"}: + return getattr(import_module("smolagents.agent_types"), name) + raise AttributeError(name) diff --git a/sdk/nexent/core/agents/sandbox/config.py b/sdk/nexent/core/agents/sandbox/config.py new file mode 100644 index 0000000000..f025b934c6 --- /dev/null +++ b/sdk/nexent/core/agents/sandbox/config.py @@ -0,0 +1,129 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Shared sandbox configuration; no executor construction or environment reads.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Optional + +from nexent.core.agents.resources.workspace import SandboxWorkspace, validate_container_root + + +class SandboxLevel(str, Enum): + """Sandbox isolation level, ordered by increasing security.""" + + LOCAL = "local" + DOCKER = "docker" + WASM = "wasm" + + +class SandboxScope(str, Enum): + """ + Container lifecycle scope — controls when a sandbox container is created + and destroyed. + + - SESSION (default): one container per agent_run, destroyed when the run ends. + Provides strict multi-tenant isolation between concurrent runs. + + - SYSTEM: a persistent Docker container shared by all agent runs system-wide. + Each run receives a dedicated Jupyter kernel in that container, so kernel + state is isolated between concurrent runs while container cold-start is + avoided. The container remains until application shutdown or failure. + """ + + SESSION = "session" + SYSTEM = "system" + + +class ShellPolicy(str, Enum): + """ + Shell command execution policy inside the sandbox container. + + - DISABLED (recommended default): blocks ``subprocess`` and ``os`` shell + invocations at AST-parse time before they reach the container. + + - RESTRICTED: V2 — allows only an explicit command allowlist. + + - BOXED: no interception; container filesystem isolation is the only + guard. NOT recommended for multi-tenant deployments. + """ + + DISABLED = "disabled" + RESTRICTED = "restricted" + BOXED = "boxed" + + +@dataclass +class SandboxConfig: + """ + Sandbox execution configuration, injected by the backend service layer. + + Every field is optional; defaults match the existing process-local behaviour + so that an all-None / all-default config is equivalent to leaving sandboxing + disabled. + """ + + level: SandboxLevel = SandboxLevel.LOCAL + scope: SandboxScope = SandboxScope.SESSION + docker_image: str = "nexent/nexent-sandbox:latest" + memory_limit_mb: int = 2048 + cpu_quota: float = 1.0 + network_disabled: bool = True + timeout_seconds: int = 120 + host_tool_timeout_seconds: Optional[float] = None + shell_policy: ShellPolicy = ShellPolicy.DISABLED + output_dir: str = "/home/sandbox/workdir/output" + auto_sync_outputs: bool = True + extra_kwargs: dict[str, Any] = field(default_factory=dict) + workspace_mode: str = "legacy" + container_workspace_root: str = "" + failure_policy: str = "local" + + def __post_init__(self) -> None: + if self.workspace_mode not in {"legacy", "bind"}: + raise ValueError("Unknown sandbox workspace mode") + if self.failure_policy not in {"local", "error"}: + raise ValueError("Unknown sandbox failure policy") + if self.workspace_mode == "bind": + validate_container_root(self.container_workspace_root) + + def bind_workspace(self) -> Optional[SandboxWorkspace]: + """Build the explicit mount mapping; never infer a mode from the host OS.""" + if self.workspace_mode != "bind": + return None + root = self.extra_kwargs.get("workspace_root") + if not root or not Path(root).is_absolute(): + raise ValueError("bind workspace requires an absolute host workspace_root") + return SandboxWorkspace(Path(root), validate_container_root(self.container_workspace_root)) + + @classmethod + def from_dict(cls, data: Optional[dict[str, Any]]) -> "SandboxConfig": + """Build a SandboxConfig from a plain dict (e.g. from AgentConfig.sandbox_policy).""" + if not data: + return cls() + host_tool_timeout_raw = data.get("host_tool_timeout_seconds") + host_tool_timeout_seconds = None + if host_tool_timeout_raw not in (None, ""): + parsed_timeout = float(host_tool_timeout_raw) + if parsed_timeout > 0: + host_tool_timeout_seconds = parsed_timeout + return cls( + level=SandboxLevel(data.get("level", "local")), + scope=SandboxScope(data.get("scope", "session")), + docker_image=data.get("docker_image", "nexent/nexent-sandbox:latest"), + memory_limit_mb=int(data.get("memory_limit_mb", 2048)), + cpu_quota=float(data.get("cpu_quota", 1.0)), + network_disabled=bool(data.get("network_disabled", True)), + timeout_seconds=int(data.get("timeout_seconds", 120)), + host_tool_timeout_seconds=host_tool_timeout_seconds, + shell_policy=ShellPolicy(data.get("shell_policy", "disabled")), + output_dir=data.get("output_dir", "/home/sandbox/workdir/output"), + auto_sync_outputs=bool(data.get("auto_sync_outputs", True)), + extra_kwargs=data.get("extra_kwargs", {}), + workspace_mode=data.get("workspace_mode", "legacy"), + container_workspace_root=data.get("container_workspace_root", ""), + failure_policy=data.get("failure_policy", "local"), + ) diff --git a/sdk/nexent/core/agents/sandbox.py b/sdk/nexent/core/agents/sandbox/execution.py similarity index 94% rename from sdk/nexent/core/agents/sandbox.py rename to sdk/nexent/core/agents/sandbox/execution.py index 4baf85e2f7..fb027f3970 100644 --- a/sdk/nexent/core/agents/sandbox.py +++ b/sdk/nexent/core/agents/sandbox/execution.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """ Sandbox executor factory and lifecycle management. @@ -43,11 +45,26 @@ from typing import Any, Optional from urllib.parse import urlsplit -from nexent.core.agents.sandbox_workspace import SandboxWorkspace, validate_container_root -from nexent.core.agents.sandbox_tls import ( - TLS_BOOTSTRAP, TLS_CERTIFICATE, TLS_DIRECTORY, TLS_LABEL, TLS_VERSION, - SandboxTLSClient, SandboxTLSMigrationRequired, SandboxTLSRecoveryError, - load_container_tls, require_tls_container, +from nexent.core.agents.resources.workspace import SandboxWorkspace, validate_container_root +from nexent.core.agents.sandbox.compatibility import ( + _AgentLoggerAdapter, + _LogLevel, + _make_code_output, + _make_local_executor, + _make_smolagents_logger, +) +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel, SandboxScope, ShellPolicy +from nexent.core.agents.sandbox.tls import ( + TLS_BOOTSTRAP, + TLS_CERTIFICATE, + TLS_DIRECTORY, + TLS_LABEL, + TLS_VERSION, + SandboxTLSClient, + SandboxTLSMigrationRequired, + SandboxTLSRecoveryError, + load_container_tls, + require_tls_container, ) from nexent.core.concurrency import ( ManagedTaskSpec, @@ -58,15 +75,15 @@ ) -logger = logging.getLogger(__name__) +logger = logging.getLogger("nexent.core.agents.sandbox") def _get_sandbox_thread_manager(): manager = get_current_thread_manager() or get_default_thread_manager() if manager is None: - from nexent.core.agents.run_agent import _get_default_agent_thread_manager + from nexent.core.concurrency.helpers import get_fallback_thread_manager - manager = _get_default_agent_thread_manager() + manager = get_fallback_thread_manager() return manager @@ -541,7 +558,7 @@ def cleanup(self) -> None: def _serialize_tool_bridge_value(value: Any) -> Any: """Convert host-tool results into a lossless JSON-compatible value.""" try: - from smolagents.agent_types import AgentAudio, AgentImage + from nexent.core.agents.sandbox.compatibility import AgentAudio, AgentImage except ImportError: # pragma: no cover - smolagents is a runtime dependency AgentAudio = AgentImage = () @@ -631,187 +648,16 @@ def _deserialize_tool_bridge_value(value: Any, tools: dict[str, Any]) -> Any: # ---------------------------------------------------------------------- -class _LogLevel(IntEnum): - """Minimal LogLevel enum compatible with smolagents.monitoring.LogLevel.""" - - OFF = -1 - ERROR = 0 - INFO = 1 - DEBUG = 2 - - -class _AgentLoggerAdapter: - """ - Thin adapter that satisfies ``smolagents.AgentLogger``'s ``.log(*args, level=...)`` - call signature while routing output through the standard ``logging.Logger``. - - ``DockerExecutor`` (and other remote executors) call:: - - self.logger.log("message", level=LogLevel.INFO) - - Standard ``logging.Logger`` uses:: - - logger.log(level, "message") # positional: (int, str) - - This adapter bridges the two by accepting the smolagents signature and - forwarding to the underlying logger with the correct argument order. - """ - - def __init__(self, delegate: logging.Logger) -> None: - self._delegate = delegate - self._level_map = { - _LogLevel.OFF: logging.CRITICAL + 1, - _LogLevel.ERROR: logging.ERROR, - _LogLevel.INFO: logging.INFO, - _LogLevel.DEBUG: logging.DEBUG, - } - - def log(self, *args: Any, level: int | str | _LogLevel = _LogLevel.INFO, **kwargs: Any) -> None: - """smolagents-compatible log(): first positional arg is the message.""" - if isinstance(level, str): - level = _LogLevel[level.upper()] - numeric = self._level_map.get(_LogLevel(int(level)), logging.INFO) - if self._delegate.isEnabledFor(numeric): - # ``*args`` contains the message(s) from smolagents AgentLogger; - # ``self._delegate.log`` expects (level, msg) — swap argument order. - self._delegate.log(numeric, " ".join(str(a) for a in args), **kwargs) - - def log_error(self, message: str) -> None: - self._delegate.error(message) - - -def _make_smolagents_logger(logger_: logging.Logger) -> _AgentLoggerAdapter: - """Wrap a standard Logger into an AgentLogger-compatible adapter.""" - return _AgentLoggerAdapter(logger_) - - # ---------------------------------------------------------------------- # Enums # ---------------------------------------------------------------------- -class SandboxLevel(str, Enum): - """Sandbox isolation level, ordered by increasing security.""" - - LOCAL = "local" - DOCKER = "docker" - WASM = "wasm" - - -class SandboxScope(str, Enum): - """ - Container lifecycle scope — controls when a sandbox container is created - and destroyed. - - - SESSION (default): one container per agent_run, destroyed when the run ends. - Provides strict multi-tenant isolation between concurrent runs. - - - SYSTEM: a persistent Docker container shared by all agent runs system-wide. - Each run receives a dedicated Jupyter kernel in that container, so kernel - state is isolated between concurrent runs while container cold-start is - avoided. The container remains until application shutdown or failure. - """ - - SESSION = "session" - SYSTEM = "system" - - -class ShellPolicy(str, Enum): - """ - Shell command execution policy inside the sandbox container. - - - DISABLED (recommended default): blocks ``subprocess`` and ``os`` shell - invocations at AST-parse time before they reach the container. - - - RESTRICTED: V2 — allows only an explicit command allowlist. - - - BOXED: no interception; container filesystem isolation is the only - guard. NOT recommended for multi-tenant deployments. - """ - - DISABLED = "disabled" - RESTRICTED = "restricted" - BOXED = "boxed" - - # ---------------------------------------------------------------------- # Configuration # ---------------------------------------------------------------------- -@dataclass -class SandboxConfig: - """ - Sandbox execution configuration, injected by the backend service layer. - - Every field is optional; defaults match the existing process-local behaviour - so that an all-None / all-default config is equivalent to leaving sandboxing - disabled. - """ - - level: SandboxLevel = SandboxLevel.LOCAL - scope: SandboxScope = SandboxScope.SESSION - docker_image: str = "nexent/nexent-sandbox:latest" - memory_limit_mb: int = 2048 - cpu_quota: float = 1.0 - network_disabled: bool = True - timeout_seconds: int = 120 - host_tool_timeout_seconds: Optional[float] = None - shell_policy: ShellPolicy = ShellPolicy.DISABLED - output_dir: str = "/home/sandbox/workdir/output" - auto_sync_outputs: bool = True - extra_kwargs: dict[str, Any] = field(default_factory=dict) - workspace_mode: str = "legacy" - container_workspace_root: str = "" - failure_policy: str = "local" - - def __post_init__(self) -> None: - if self.workspace_mode not in {"legacy", "bind"}: - raise ValueError("Unknown sandbox workspace mode") - if self.failure_policy not in {"local", "error"}: - raise ValueError("Unknown sandbox failure policy") - if self.workspace_mode == "bind": - validate_container_root(self.container_workspace_root) - - def bind_workspace(self) -> Optional[SandboxWorkspace]: - """Build the explicit mount mapping; never infer a mode from the host OS.""" - if self.workspace_mode != "bind": - return None - root = self.extra_kwargs.get("workspace_root") - if not root or not Path(root).is_absolute(): - raise ValueError("bind workspace requires an absolute host workspace_root") - return SandboxWorkspace(Path(root), validate_container_root(self.container_workspace_root)) - - @classmethod - def from_dict(cls, data: Optional[dict[str, Any]]) -> "SandboxConfig": - """Build a SandboxConfig from a plain dict (e.g. from AgentConfig.sandbox_policy).""" - if not data: - return cls() - host_tool_timeout_raw = data.get("host_tool_timeout_seconds") - host_tool_timeout_seconds = None - if host_tool_timeout_raw not in (None, ""): - parsed_timeout = float(host_tool_timeout_raw) - if parsed_timeout > 0: - host_tool_timeout_seconds = parsed_timeout - return cls( - level=SandboxLevel(data.get("level", "local")), - scope=SandboxScope(data.get("scope", "session")), - docker_image=data.get("docker_image", "nexent/nexent-sandbox:latest"), - memory_limit_mb=int(data.get("memory_limit_mb", 2048)), - cpu_quota=float(data.get("cpu_quota", 1.0)), - network_disabled=bool(data.get("network_disabled", True)), - timeout_seconds=int(data.get("timeout_seconds", 120)), - host_tool_timeout_seconds=host_tool_timeout_seconds, - shell_policy=ShellPolicy(data.get("shell_policy", "disabled")), - output_dir=data.get("output_dir", "/home/sandbox/workdir/output"), - auto_sync_outputs=bool(data.get("auto_sync_outputs", True)), - extra_kwargs=data.get("extra_kwargs", {}), - workspace_mode=data.get("workspace_mode", "legacy"), - container_workspace_root=data.get("container_workspace_root", ""), - failure_policy=data.get("failure_policy", "local"), - ) - - # ---------------------------------------------------------------------- # Shell-call interceptor (§6.B) # ---------------------------------------------------------------------- @@ -971,16 +817,6 @@ def wrapped_call(code: str) -> Any: return executor -def _make_code_output(logs: str) -> Any: - """Return the executor result shape expected by the agent runtime.""" - try: - from smolagents.remote_executors import CodeOutput - - return CodeOutput(output=None, logs=logs, is_final_answer=False) - except ImportError: - return SimpleNamespace(output=None, logs=logs, is_final_answer=False) - - _ONLINE_USER_SITE_BOOTSTRAP = ( "import importlib as _nexent_importlib, site as _nexent_site, sys as _nexent_sys\n" "_nexent_user_site = _nexent_site.getusersitepackages()\n" @@ -1828,8 +1664,10 @@ def _wait_for_kernel_channel_ready(self, ws: Any) -> None: def _kernel_channel(self): """Retry only the side-effect-free handshake, never a submitted execution.""" from websocket import ( - WebSocketBadStatusException, WebSocketConnectionClosedException, - WebSocketTimeoutException, create_connection, + WebSocketBadStatusException, + WebSocketConnectionClosedException, + WebSocketTimeoutException, + create_connection, ) channel_host = urlsplit(self.ws_url).hostname @@ -1903,18 +1741,18 @@ def container(self) -> Any: def run_code_raise_errors(self, code: str) -> Any: import json - from smolagents.remote_executors import ( - AgentError, - CodeOutput, - RemotePythonExecutor, - _websocket_send_execute_request, - ) from websocket import ( ABNF, WebSocketConnectionClosedException, WebSocketTimeoutException, ) + from nexent.core.agents.sandbox.compatibility import ( + AgentError, + CodeOutput, + _websocket_send_execute_request, + ) + self._check_execution_cancelled() if self._closed: raise RuntimeError("Sandbox kernel lease is already closed") @@ -2055,7 +1893,7 @@ def _replace_unhealthy_kernel(self) -> None: def _replace_kernel_and_restore_state(self) -> None: """Replace a failed kernel and restore framework-managed execution state.""" - from smolagents.remote_executors import ( + from nexent.core.agents.sandbox.compatibility import ( RemotePythonExecutor, ) @@ -2145,7 +1983,7 @@ def __call__(self, code_action: str) -> Any: return self.run_code_raise_errors(code_action) def send_variables(self, variables: dict[str, Any]) -> None: - from smolagents.remote_executors import RemotePythonExecutor + from nexent.core.agents.sandbox.compatibility import RemotePythonExecutor self._cached_variables = dict(variables) recovery_count = getattr(self, "_kernel_replacement_count", 0) try: @@ -2164,11 +2002,11 @@ def send_variables(self, variables: dict[str, Any]) -> None: self._replace_unhealthy_kernel() def install_packages(self, additional_imports: list[str]) -> list[str]: - from smolagents.remote_executors import RemotePythonExecutor + from nexent.core.agents.sandbox.compatibility import RemotePythonExecutor return RemotePythonExecutor.install_packages(self, additional_imports) def send_tools(self, tools: dict[str, Any]) -> None: - from smolagents.remote_executors import RemotePythonExecutor + from nexent.core.agents.sandbox.compatibility import RemotePythonExecutor self._cached_tools = dict(tools) recovery_count = getattr(self, "_kernel_replacement_count", 0) try: @@ -3162,7 +3000,7 @@ def _build_docker_executor( ) -> Any: """Construct a Docker executor with Nexent hardening.""" try: - from smolagents.remote_executors import DockerExecutor + from nexent.core.agents.sandbox.compatibility import DockerExecutor if DockerExecutor is None: raise ImportError("DockerExecutor is unavailable") @@ -3317,8 +3155,8 @@ def _build_wasm_executor( ) -> Any: """Construct a smolagents WasmExecutor.""" try: - from smolagents.remote_executors import WasmExecutor - except ImportError as exc: + from nexent.core.agents.sandbox.compatibility import WasmExecutor + except ImportError: logger_.error( "WasmExecutor requires smolagents[wasm]. " "Install it with: pip install 'smolagents[wasm]'. " @@ -3439,14 +3277,6 @@ def _wrap_executor(executor: Any, config: SandboxConfig, logger_: logging.Logger return executor -def _make_local_executor(additional_imports: list[str]) -> Any: - """Build a LocalPythonExecutor with the standard safe-import list.""" - from smolagents.local_python_executor import LocalPythonExecutor - executor = LocalPythonExecutor(additional_imports) - executor._nexent_backend = "local" - return executor - - def _now() -> float: import time return time.time() @@ -3538,3 +3368,18 @@ def release_python_executor(executor: Any, logger_: logging.Logger) -> None: return pool = SandboxPoolManager.get_instance() pool.release(executor, logger_) + + +__all__ = ['Any', 'BaseHTTPRequestHandler', 'Context', 'Enum', 'FuturesCancelledError', 'FuturesTimeoutError', + 'IntEnum', 'ManagedTaskSpec', 'ManagedThreadSpec', 'Optional', 'Path', 'RunCancellationScope', + 'SANDBOX_CONTAINER_NAME', 'SANDBOX_JUPYTER_PORT', 'SANDBOX_NETWORK_NAME', 'SANDBOX_PNPM_STORE_PATH', + 'SANDBOX_PNPM_STORE_SOURCE', 'SANDBOX_SESSION_CONTAINER_PREFIX', 'SandboxConfig', 'SandboxLevel', + 'SandboxPoolManager', 'SandboxScope', 'SandboxSkillScriptRunner', 'SandboxTLSClient', + 'SandboxTLSMigrationRequired', 'SandboxTLSRecoveryError', 'SandboxWorkspace', 'ShellPolicy', + 'SimpleNamespace', 'TLS_BOOTSTRAP', 'TLS_CERTIFICATE', 'TLS_DIRECTORY', 'TLS_LABEL', 'TLS_VERSION', + 'ThreadingHTTPServer', 'ast', 'base64', 'build_python_executor', 'cleanup_executor', 'closing', + 'contextmanager', 'copy_context', 'dataclass', 'field', 'get_current_thread_manager', + 'get_default_thread_manager', 'hashlib', 'hmac', 'io', 'ipaddress', 'json', 'load_container_tls', + 'logger', 'logging', 'mimetypes', 're', 'release_python_executor', 'require_tls_container', 'secrets', + 'shlex', 'socket', 'tarfile', 'threading', 'time', 'urlsplit', 'validate_container_root', '_LogLevel', + '_AgentLoggerAdapter', '_make_smolagents_logger', '_make_code_output', '_make_local_executor'] diff --git a/sdk/nexent/core/agents/sandbox_tls.py b/sdk/nexent/core/agents/sandbox/tls.py similarity index 95% rename from sdk/nexent/core/agents/sandbox_tls.py rename to sdk/nexent/core/agents/sandbox/tls.py index 8604db25c3..6559b6bb6a 100644 --- a/sdk/nexent/core/agents/sandbox_tls.py +++ b/sdk/nexent/core/agents/sandbox/tls.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Per-container Jupyter TLS, with trust obtained through the Docker API.""" import io @@ -24,7 +26,7 @@ class SandboxTLSRecoveryError(RuntimeError): # Executed by the image's configured user, before any kernel can run. The key # never leaves the container, and neither the command nor Docker labels contain it. -TLS_BOOTSTRAP = Path(__file__).with_name('sandbox_tls_bootstrap.py').read_text(encoding='utf-8') +TLS_BOOTSTRAP = Path(__file__).with_name('tls_bootstrap.py').read_text(encoding='utf-8') def require_tls_container(container) -> None: diff --git a/sdk/nexent/core/agents/sandbox_tls_bootstrap.py b/sdk/nexent/core/agents/sandbox/tls_bootstrap.py similarity index 98% rename from sdk/nexent/core/agents/sandbox_tls_bootstrap.py rename to sdk/nexent/core/agents/sandbox/tls_bootstrap.py index 1c65c19753..f267b22a2b 100644 --- a/sdk/nexent/core/agents/sandbox_tls_bootstrap.py +++ b/sdk/nexent/core/agents/sandbox/tls_bootstrap.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Standalone Linux container bootstrap, sent as source to existing sandbox images.""" import ipaddress diff --git a/sdk/nexent/core/agents/sandbox_workspace.py b/sdk/nexent/core/agents/sandbox_workspace.py deleted file mode 100644 index 0bdd62bfb4..0000000000 --- a/sdk/nexent/core/agents/sandbox_workspace.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Explicit host/container workspace mapping for bind-mounted executors.""" - -import hashlib -import json -import os -from dataclasses import dataclass -from pathlib import Path, PurePosixPath, PureWindowsPath - - -def validate_container_root(value: str) -> PurePosixPath: - """Reject host paths and traversal before constructing a container path.""" - path = PurePosixPath(value) - if not path.is_absolute() or path == PurePosixPath('/') or '\\' in value or ':' in value or '..' in path.parts: - raise ValueError('container workspace must be a non-root POSIX absolute path') - return path - - -@dataclass(frozen=True) -class SandboxWorkspace: - host_root: Path - container_root: PurePosixPath - - def __post_init__(self): - object.__setattr__(self, 'host_root', Path(self.host_root).resolve()) - object.__setattr__(self, 'container_root', validate_container_root(str(self.container_root))) - - def to_container(self, path: str | Path) -> PurePosixPath: - relative = Path(path).resolve().relative_to(self.host_root) - return self.container_root.joinpath(*relative.parts) - - def to_host(self, value: str) -> Path: - """Translate an absolute kernel path and enforce the resolved host boundary.""" - path = validate_container_root(value) - relative = path.relative_to(self.container_root) - # Reject Windows-special components, including alternate data streams. - if any(PureWindowsPath(part).is_reserved() for part in relative.parts): - raise ValueError('Reserved host filename') - target = self.host_root.joinpath(*relative.parts).resolve() - target.relative_to(self.host_root) - return target - - def resolve_file(self, value: str, base: str | Path) -> Path: - """Resolve a tool path without changing that tool's relative-path convention.""" - base_path = Path(base).resolve() - base_path.relative_to(self.host_root) - if value.startswith('/'): - target = self.to_host(value) - else: - if '..' in PureWindowsPath(value).parts: - raise ValueError('Workspace traversal is not allowed') - target = (base_path / value).resolve() - target.relative_to(base_path) - return target - - def for_run(self, host_run: str | Path) -> 'SandboxWorkspace': - return SandboxWorkspace(Path(host_run), self.to_container(host_run)) - - @property - def mount_id(self) -> str: - payload = json.dumps([os.path.normcase(str(self.host_root)), str(self.container_root)]) - return hashlib.sha256(payload.encode()).hexdigest() - - def matches_mount(self, mount: dict) -> bool: - """Compare Desktop's Linux view of drive paths with the configured source.""" - source = str(mount.get('Source', '')).replace('\\', '/') - expected = str(self.host_root).replace('\\', '/') - if self.host_root.drive: - for prefix in ('/run/desktop/mnt/host/', '/host_mnt/'): - if source.startswith(prefix): - tail = source[len(prefix):] - if len(tail) >= 2 and tail[1] == '/': - source = tail[0] + ':' + tail[1:] - break - source, expected = source.casefold(), expected.casefold() - return ( - mount.get('Type') == 'bind' and mount.get('RW') is True - and mount.get('Destination') == str(self.container_root) - and source.rstrip('/') == expected.rstrip('/') - ) - - -def probe_workspace(container, workspace: PurePosixPath) -> None: - """Verify traversal and real read/write access as the container's configured user.""" - code = ( - 'import os,tempfile; from pathlib import Path; ' - f'p=Path({str(workspace)!r}); ' - 'os.chdir(p.parent); os.chdir(p.name); ' - '[(f.open("rb").read(1)) for f in (p/"inputs").rglob("*") if f.is_file()]; ' - 't=tempfile.TemporaryDirectory(prefix=".nexent-probe-",dir=p/"outputs"); ' - 'f=Path(t.name)/"nested"/"file.txt"; f.parent.mkdir(); ' - 'f.write_text("probe",encoding="utf-8"); ' - 'assert f.read_text(encoding="utf-8")=="probe"; t.cleanup()' - ) - result = container.exec_run(['python', '-c', code]) - if result.exit_code != 0: - raise RuntimeError(f'Sandbox workspace access failed: {result.output!r}') diff --git a/sdk/nexent/core/agents/summary_config.py b/sdk/nexent/core/agents/summary_config.py deleted file mode 100644 index 3d5afbaf3b..0000000000 --- a/sdk/nexent/core/agents/summary_config.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Deprecated compatibility path for context configuration.""" - -import warnings - -from .context.config import ContextManagerConfig - - -COMPATIBILITY_REMOVAL_VERSION = "v2.4.0" -warnings.warn( - "nexent.core.agents.summary_config is deprecated; use nexent.core.agents.context.config; " - f"the compatibility path will be removed in {COMPATIBILITY_REMOVAL_VERSION}", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["COMPATIBILITY_REMOVAL_VERSION", "ContextManagerConfig"] diff --git a/sdk/nexent/core/context_runtime/__init__.py b/sdk/nexent/core/context_runtime/__init__.py deleted file mode 100644 index 7ce9dacfa6..0000000000 --- a/sdk/nexent/core/context_runtime/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Neutral contracts for the ContextManager-backed runtime. - -The concrete runtime is intentionally not imported here so contract imports do -not initialize context assembly implementations as a side effect. -""" - -from .contracts import ( - ContextEvidence, - ContextRuntime, - FinalContext, - UnconfiguredContextRuntime, -) - - -__all__ = [ - "ContextEvidence", - "ContextRuntime", - "FinalContext", - "UnconfiguredContextRuntime", -] diff --git a/sdk/nexent/core/context_runtime/managed/__init__.py b/sdk/nexent/core/context_runtime/managed/__init__.py deleted file mode 100644 index c306c5ab66..0000000000 --- a/sdk/nexent/core/context_runtime/managed/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Deprecated compatibility package for the agent-owned context runtime.""" - -from .runtime import ManagedContextRuntime - - -__all__ = ["ManagedContextRuntime"] diff --git a/sdk/nexent/core/context_runtime/managed/runtime.py b/sdk/nexent/core/context_runtime/managed/runtime.py deleted file mode 100644 index 6cbcdf4b11..0000000000 --- a/sdk/nexent/core/context_runtime/managed/runtime.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Deprecated compatibility path for the agent context runtime.""" - -import warnings - -from ...agents.context.runtime import ManagedContextRuntime - - -COMPATIBILITY_REMOVAL_VERSION = "v2.4.0" -warnings.warn( - "nexent.core.context_runtime.managed is deprecated; use nexent.core.agents.context.runtime; " - f"the compatibility path will be removed in {COMPATIBILITY_REMOVAL_VERSION}", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["COMPATIBILITY_REMOVAL_VERSION", "ManagedContextRuntime"] diff --git a/sdk/nexent/core/model_service/resource_conversion.py b/sdk/nexent/core/model_service/resource_conversion.py index 7cf756d0fa..8808b730da 100644 --- a/sdk/nexent/core/model_service/resource_conversion.py +++ b/sdk/nexent/core/model_service/resource_conversion.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Convert already-authorized resources; execution and prompt policy stay in Runtime.""" import json from typing import Literal from uuid import uuid4 -from nexent.core.agent_runtime_support.contracts import RuntimeResourceView as _RuntimeResourceView +from nexent.core.agents.contracts.resources import RuntimeResourceView as _RuntimeResourceView from nexent.core.model_service.contracts import ModelMessage, ModelRequest from nexent.core.model_service.profiles.contracts import ProfileContract, VersionedReference from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping diff --git a/sdk/nexent/core/tools/parallel_executor.py b/sdk/nexent/core/tools/parallel_executor.py index f6e32f5b1d..28906b0040 100644 --- a/sdk/nexent/core/tools/parallel_executor.py +++ b/sdk/nexent/core/tools/parallel_executor.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import threading from concurrent.futures import TimeoutError as FutureTimeoutError from copy import deepcopy @@ -140,7 +141,7 @@ def _execute_tasks(tasks, names, timeout, max_workers, *, lane="model-tool-io"): manager = get_current_thread_manager() if manager is None: - from ..agents.run_agent import _get_default_agent_thread_manager + from nexent.core.agents.orchestration.entry import _get_default_agent_thread_manager manager = _get_default_agent_thread_manager() requested_limit = max(1, int(max_workers)) diff --git a/sdk/nexent/core/tools/plan_tools.py b/sdk/nexent/core/tools/plan_tools.py index 221022d029..7db5775c7d 100644 --- a/sdk/nexent/core/tools/plan_tools.py +++ b/sdk/nexent/core/tools/plan_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Plan management tools for the agent. These two tools implement v1.4 of the planning feature: instead of relying on @@ -98,7 +99,7 @@ def __init__( def forward(self, title: str, steps: list) -> dict: # Local import avoids pulling agent_model into the tool module's # import graph; agent_model imports from us via __init__. - from ..agents.agent_model import AgentPlan, PlanStep + from nexent.core.agents.contracts.config import AgentPlan, PlanStep if not isinstance(steps, list) or len(steps) < 3: raise ValueError( diff --git a/sdk/nexent/core/tools/search_memory_tool.py b/sdk/nexent/core/tools/search_memory_tool.py index feb6d607f4..2967e48e5b 100644 --- a/sdk/nexent/core/tools/search_memory_tool.py +++ b/sdk/nexent/core/tools/search_memory_tool.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Search memory tool for the new Memory system. This tool is invoked once by the runtime before the agent model loop. Under @@ -39,7 +40,7 @@ def _run_coroutine(coro): return asyncio.run(coro) manager = get_current_thread_manager() if manager is None: - from ..agents.run_agent import _get_default_agent_thread_manager + from nexent.core.agents.orchestration.entry import _get_default_agent_thread_manager manager = _get_default_agent_thread_manager() return manager.run_sync( diff --git a/sdk/nexent/core/tools/store_memory_tool.py b/sdk/nexent/core/tools/store_memory_tool.py index 4c3f1a8704..1a56e67d1c 100644 --- a/sdk/nexent/core/tools/store_memory_tool.py +++ b/sdk/nexent/core/tools/store_memory_tool.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Store memory tool for the new Memory system. This tool is invoked by the agent to persist important information to its @@ -32,7 +33,7 @@ def _run_coroutine(coro): return asyncio.run(coro) manager = get_current_thread_manager() if manager is None: - from ..agents.run_agent import _get_default_agent_thread_manager + from nexent.core.agents.orchestration.entry import _get_default_agent_thread_manager manager = _get_default_agent_thread_manager() return manager.run_sync( diff --git a/test-e2e/cases/AGT-AUTO-01804EF17A1DF51B/test.py b/test-e2e/cases/AGT-AUTO-01804EF17A1DF51B/test.py index 1a4efd3d83..b6b345fd77 100644 --- a/test-e2e/cases/AGT-AUTO-01804EF17A1DF51B/test.py +++ b/test-e2e/cases/AGT-AUTO-01804EF17A1DF51B/test.py @@ -16,7 +16,7 @@ from contracts.conversation import HistoryItem from contracts.agent import NL2AgentRunRequest -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig from nexent.core.agents.context import ContextItemInput, ContextItemType from nexent.core.models.capacity_budget import ContextBudgetSnapshot from nexent.core.models.capacity_resolver import ModelCapacitySnapshot diff --git a/test-e2e/cases/CMSR-D1-003/test.py b/test-e2e/cases/CMSR-D1-003/test.py index f45e565d39..7a070beeab 100644 --- a/test-e2e/cases/CMSR-D1-003/test.py +++ b/test-e2e/cases/CMSR-D1-003/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """CMSR-D1-003: semantic repair uses the same streamed attempt lifecycle.""" import json @@ -8,9 +9,9 @@ import pytest -from nexent.core.agents.core_agent import CoreAgent -from nexent.core.agents import core_agent as agent_module -from nexent.core.agents.output_protocol import ModelOutputProtocolError +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.cores.smolagents import core_agent as agent_module +from nexent.core.agents.cores.smolagents.output_protocol import ModelOutputProtocolError from nexent.core.utils.observer import MessageObserver pytestmark = [pytest.mark.case_id("CMSR-D1-003"), pytest.mark.stage("D1")] diff --git a/test-e2e/cases/CMSR-D1-004/test.py b/test-e2e/cases/CMSR-D1-004/test.py index e67aad46a4..e5de1a9c3e 100644 --- a/test-e2e/cases/CMSR-D1-004/test.py +++ b/test-e2e/cases/CMSR-D1-004/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """CMSR-D1-004: correlated protocol diagnostics and opt-in preview privacy.""" from __future__ import annotations @@ -7,11 +8,8 @@ import pytest -from nexent.core.agents.core_agent import CoreAgent -from nexent.core.agents.output_protocol import ( - ModelOutputProtocolError, - ProtocolErrorReason, -) +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.cores.smolagents.output_protocol import ModelOutputProtocolError, ProtocolErrorReason pytestmark = [pytest.mark.case_id("CMSR-D1-004"), pytest.mark.stage("D1")] diff --git a/test-e2e/cases/FAF-D1-001/test.py b/test-e2e/cases/FAF-D1-001/test.py index 0fae311ae8..02232aec56 100644 --- a/test-e2e/cases/FAF-D1-001/test.py +++ b/test-e2e/cases/FAF-D1-001/test.py @@ -118,7 +118,7 @@ def test_faf_d1_001_reasoning_protocol_quote_is_not_answer(): def test_faf_d1_001_managed_agent_completes_one_answer(mocker): - from nexent.core.agents.core_agent import CoreAgent + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent observer = MessageObserver() observer.add_subagent_start("child", "Child", invocation_id="child") @@ -140,7 +140,7 @@ def test_faf_d1_001_managed_agent_completes_one_answer(mocker): def test_faf_d1_001_legacy_envelope_retains_trailing_answer(mocker): - from nexent.core.agents.core_agent import CoreAgent + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent agent = object.__new__(CoreAgent) agent.observer = MessageObserver() @@ -173,7 +173,7 @@ def test_faf_d1_001_legacy_completion_moves_body_without_retransmission(): @pytest.mark.parametrize("prefix", ["", "Thought. "]) def test_faf_d1_001_merged_code_action_accepts_final_envelope(prefix): - from nexent.core.agents.output_protocol import ExplicitFinalAnswer, classify_model_output + from nexent.core.agents.cores.smolagents.output_protocol import ExplicitFinalAnswer, classify_model_output result = classify_model_output(prefix + "Final", protocol="code_action") assert isinstance(result, ExplicitFinalAnswer) diff --git a/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py index 8095ac85d8..e5b8e63722 100644 --- a/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py +++ b/test-e2e/cases/JIUWEN-RECOVERY-D5-001/test_recovery.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Bounded recovery through the actual Runner, model transport and tool dispatcher.""" import asyncio @@ -160,7 +161,7 @@ def config(**kwargs): def test_recovery_respects_remaining_run_budget(monkeypatch, fault): import time - from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter monkeypatch.setattr(bridge, "provider", provider) original = JiuwenAdapter._create_model diff --git a/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py b/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py index 6e0d1139e7..bf6fe2a849 100644 --- a/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py +++ b/test-e2e/cases/JIUWEN-RELEASE-D3-001/deployed_smoke.py @@ -15,16 +15,10 @@ import httpx from management.db.client import get_db_session from management.db.models import ConversationMessage, ModelRecord, UserTenant -from nexent.core.agents import nexent_agent -from nexent.core.agents.agent_model import ( - AgentConfig, - AgentHistory, - AgentRunInfo, - ExternalA2AAgentConfig, - ModelConfig, - ToolConfig, -) -from nexent.core.agents.run_agent import agent_run +from nexent.core.agents.cores.smolagents import agent as nexent_agent +from nexent.core.agents.contracts.config import AgentConfig, ExternalA2AAgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver from management.services.configuration.user.memory import get_memory_switch diff --git a/test-e2e/cases/REL-AUTO-1FAFCA5A681580F5/test.py b/test-e2e/cases/REL-AUTO-1FAFCA5A681580F5/test.py index 6c60955819..1ab65d5352 100644 --- a/test-e2e/cases/REL-AUTO-1FAFCA5A681580F5/test.py +++ b/test-e2e/cases/REL-AUTO-1FAFCA5A681580F5/test.py @@ -57,7 +57,7 @@ def _install_shutdown_collaborators(monkeypatch, *, raise_thread_shutdown: bool) from infrastructure.concurrency import runtime_thread_manager from management.services.agent.run_state import runtime_state_service from management.services.agent import run as agent_run - import nexent.core.agents.sandbox as sandbox_mod + import nexent.core.agents.sandbox.execution as sandbox_mod events: list[tuple[str, object]] = [] diff --git a/test-e2e/cases/SAI-D1-001/test.py b/test-e2e/cases/SAI-D1-001/test.py index 4cf2ff358b..f0a7e97a4a 100644 --- a/test-e2e/cases/SAI-D1-001/test.py +++ b/test-e2e/cases/SAI-D1-001/test.py @@ -1,9 +1,10 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """SAI-D1-001: case-local preserved component regression.""" import threading from types import SimpleNamespace import pytest -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.tools.parallel_executor import _parallel_executor from shared.sai_component import ( ORIGINAL_SANDBOX_CLEANUP as ORIGINAL_SANDBOX_CLEANUP, diff --git a/test-e2e/cases/SAI-D1-002/test.py b/test-e2e/cases/SAI-D1-002/test.py index 38a5e3af94..0ed66b3a1e 100644 --- a/test-e2e/cases/SAI-D1-002/test.py +++ b/test-e2e/cases/SAI-D1-002/test.py @@ -1,9 +1,10 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """SAI-D1-002: case-local preserved component regression.""" import threading from types import SimpleNamespace import pytest -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.concurrency import ManagedTaskSpec from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated from shared.sai_component import ( diff --git a/test-e2e/cases/SAI-D1-005/test.py b/test-e2e/cases/SAI-D1-005/test.py index b3b26b79fe..aac65417e6 100644 --- a/test-e2e/cases/SAI-D1-005/test.py +++ b/test-e2e/cases/SAI-D1-005/test.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """SAI-D1-005: case-local preserved component regression.""" from pathlib import Path from types import SimpleNamespace import pytest -from nexent.core.agents import sandbox -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.cores.smolagents.agent import NexentAgent from shared.sai_component import ( ORIGINAL_SANDBOX_CLEANUP as ORIGINAL_SANDBOX_CLEANUP, ) diff --git a/test-e2e/cases/SEARCH-DEEPSEEK-D3-001/test_integration.py b/test-e2e/cases/SEARCH-DEEPSEEK-D3-001/test_integration.py index 538face0db..26aa340657 100644 --- a/test-e2e/cases/SEARCH-DEEPSEEK-D3-001/test_integration.py +++ b/test-e2e/cases/SEARCH-DEEPSEEK-D3-001/test_integration.py @@ -8,8 +8,8 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import ToolConfig -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.contracts.config import ToolConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.tools import DeepSeekSearchTool from nexent.core.utils.observer import MessageObserver, ProcessType diff --git a/test-e2e/cases/TFP-D1-001/test.py b/test-e2e/cases/TFP-D1-001/test.py index 04fd0454cc..5f3c6df598 100644 --- a/test-e2e/cases/TFP-D1-001/test.py +++ b/test-e2e/cases/TFP-D1-001/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """TFP-D1-001: preserved fast-path acceptance checks.""" from __future__ import annotations @@ -8,9 +9,7 @@ from shared.tool_fastpath import make_wrapper from shared.tool_fastpath_aidp import aidp_runtime as aidp_runtime -from nexent.core.agents.tool_fastpath import ( - DeferredToolExecutor, -) +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.utils.observer import MessageObserver, ProcessType @@ -19,8 +18,8 @@ def test_tfp_d1_001(monkeypatch): """TFP-D1-001: assignment, logs and saved final answer without acquisition.""" wrapper, ordinary, calls, acquisitions, releases = make_wrapper(monkeypatch) - from nexent.core.agents.core_agent import _wrap_tool_for_observer - from nexent.core.agents.nexent_agent import _wrap_tool_with_monitoring + from nexent.core.agents.cores.smolagents.core_agent import _wrap_tool_for_observer + from nexent.core.agents.cores.smolagents.agent import _wrap_tool_with_monitoring observer = MessageObserver() events = [] diff --git a/test-e2e/cases/TFP-D1-004/test.py b/test-e2e/cases/TFP-D1-004/test.py index 6d30e746a9..4cb03e88ee 100644 --- a/test-e2e/cases/TFP-D1-004/test.py +++ b/test-e2e/cases/TFP-D1-004/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """TFP-D1-004: preserved fast-path acceptance checks.""" from __future__ import annotations @@ -9,13 +10,8 @@ from shared.tool_fastpath_aidp import aidp_runtime as aidp_runtime from smolagents.default_tools import FinalAnswerTool -from nexent.core.agents.agent_model import ToolConfig -from nexent.core.agents.tool_fastpath import ( - OFFICIAL_FASTPATH_TOOL_CLASSES, - DeferredToolExecutor, - is_simple_whitelisted_action, - official_fastpath_tools, -) +from nexent.core.agents.contracts.config import ToolConfig +from nexent.core.agents.cores.smolagents.tool_fastpath import OFFICIAL_FASTPATH_TOOL_CLASSES, DeferredToolExecutor, is_simple_whitelisted_action, official_fastpath_tools from nexent.core.ext_components.aidp.aidp_search_tool import AidpSearchTool from nexent.core.tools.ind_aidp_search_tool import IndependentAidpSearchTool from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool diff --git a/test-e2e/cases/TFP-D1-005/test.py b/test-e2e/cases/TFP-D1-005/test.py index 29b3f1591d..9999f46219 100644 --- a/test-e2e/cases/TFP-D1-005/test.py +++ b/test-e2e/cases/TFP-D1-005/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """TFP-D1-005: preserved fast-path acceptance checks.""" from __future__ import annotations @@ -10,9 +11,9 @@ from shared.tool_fastpath import OrdinaryExecutor, make_wrapper from shared.tool_fastpath_aidp import aidp_runtime as aidp_runtime -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel, SandboxScope +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel, SandboxScope from nexent.core.utils.observer import MessageObserver @@ -76,8 +77,8 @@ def failing_variables(_variables): @pytest.mark.parametrize("failure", ["workspace", "workspace_cancel", "binding_cancel"]) def test_tfp_d1_005_deferred_setup(monkeypatch, tmp_path, failure): """TFP-D1-005: failed setup releases leases and a retry uses a fresh group.""" - import nexent.core.agents.nexent_agent as factory_module - import nexent.core.agents.sandbox as sandbox_module + import nexent.core.agents.cores.smolagents.agent as factory_module + import nexent.core.agents.sandbox.execution as sandbox_module builds, closed, runner_cleanup, executors = [], [], [], [] diff --git a/test-e2e/cases/TFP-D1-006/test.py b/test-e2e/cases/TFP-D1-006/test.py index a50b87ec8f..91090a6efc 100644 --- a/test-e2e/cases/TFP-D1-006/test.py +++ b/test-e2e/cases/TFP-D1-006/test.py @@ -16,12 +16,11 @@ from shared.tool_fastpath import FinalEnvelopeModel, OrdinaryExecutor from smolagents.default_tools import FinalAnswerTool -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ToolConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel, SandboxScope -from nexent.core.agents.tool_fastpath import ( - DeferredToolExecutor, -) +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel, SandboxScope +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.utils.observer import MessageObserver @@ -30,8 +29,8 @@ @pytest.mark.parametrize("activate_child", [False, True]) def test_tfp_d1_006(monkeypatch, tmp_path, activate_child): """TFP-D1-006: deferred tree activation, workspace, cleanup and disabled mode.""" - import nexent.core.agents.nexent_agent as factory_module - import nexent.core.agents.sandbox as sandbox_module + import nexent.core.agents.cores.smolagents.agent as factory_module + import nexent.core.agents.sandbox.execution as sandbox_module group = object() builds = [] @@ -149,8 +148,8 @@ def initialize(owner, executors=None): ]) def test_tfp_d1_006_backends(monkeypatch, level, backend): """TFP-D1-006: only Docker is deferred and its local fallback stays sticky.""" - import nexent.core.agents.nexent_agent as factory_module - import nexent.core.agents.sandbox as sandbox_module + import nexent.core.agents.cores.smolagents.agent as factory_module + import nexent.core.agents.sandbox.execution as sandbox_module raw = OrdinaryExecutor() raw._nexent_backend = backend @@ -219,8 +218,8 @@ def test_tfp_d1_006_configuration(enabled, allowed, expected): @pytest.mark.parametrize("setting", ["default", "missing", "disabled", "enabled"]) def test_tfp_d1_006_run_defaults(monkeypatch, with_mcp, setting): """TFP-D1-006: actual run construction stays cold by default and honors false.""" - import nexent.core.agents.run_agent as run_module - import nexent.core.agents.sandbox as sandbox_module + import nexent.core.agents.orchestration.entry as run_module + import nexent.core.agents.sandbox.execution as sandbox_module acquisitions, releases, runtimes, outputs = [], [], [], [] ordinary = OrdinaryExecutor() diff --git a/test-e2e/cases/TFP-D5-001/test.py b/test-e2e/cases/TFP-D5-001/test.py index 7bcf033823..29a57e7ffa 100644 --- a/test-e2e/cases/TFP-D5-001/test.py +++ b/test-e2e/cases/TFP-D5-001/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """TFP-D5-001: preserved fast-path acceptance checks.""" from __future__ import annotations @@ -8,7 +9,7 @@ import pytest from shared.tool_fastpath import make_wrapper -from nexent.core.agents.tool_fastpath import is_simple_whitelisted_action +from nexent.core.agents.cores.smolagents.tool_fastpath import is_simple_whitelisted_action pytestmark = [pytest.mark.case_id("TFP-D5-001"), pytest.mark.stage("D5")] diff --git a/test-e2e/cases/UT-BE-AUTO-4781AB28D8956C3F/test.py b/test-e2e/cases/UT-BE-AUTO-4781AB28D8956C3F/test.py index 06594654a2..7dfe5f6bea 100644 --- a/test-e2e/cases/UT-BE-AUTO-4781AB28D8956C3F/test.py +++ b/test-e2e/cases/UT-BE-AUTO-4781AB28D8956C3F/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import asyncio import builtins import concurrent.futures @@ -65,7 +66,7 @@ def _managed_thread_fallback(loop, fake_manager, new_event_loop=None): mock.patch('builtins.__import__', side_effect=_nest_asyncio_import(False, None)), mock.patch('nexent.core.concurrency.get_current_thread_manager', return_value=None), mock.patch( - 'nexent.core.agents.run_agent._get_default_agent_thread_manager', + 'nexent.core.agents.orchestration.entry._get_default_agent_thread_manager', return_value=fake_manager, ), ] diff --git a/test-e2e/cases/UT-BE-AUTO-F2215F8782D68127/test.py b/test-e2e/cases/UT-BE-AUTO-F2215F8782D68127/test.py index 76fd184897..9df8916c54 100644 --- a/test-e2e/cases/UT-BE-AUTO-F2215F8782D68127/test.py +++ b/test-e2e/cases/UT-BE-AUTO-F2215F8782D68127/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from __future__ import annotations import asyncio @@ -118,7 +119,7 @@ def run_sync(self, lane, spec, fn): recording = RecordingManager() monkeypatch.setattr('nexent.core.concurrency.get_current_thread_manager', lambda: None) monkeypatch.setattr( - 'nexent.core.agents.run_agent._get_default_agent_thread_manager', + 'nexent.core.agents.orchestration.entry._get_default_agent_thread_manager', lambda: recording, ) real_import = builtins.__import__ diff --git a/test-e2e/cases/UT-SDK-016/test.py b/test-e2e/cases/UT-SDK-016/test.py index f61312157f..d8fd918340 100644 --- a/test-e2e/cases/UT-SDK-016/test.py +++ b/test-e2e/cases/UT-SDK-016/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """D1 SDK unit contracts. All I/O clients are fakes owned by the test.""" from __future__ import annotations @@ -89,7 +90,7 @@ def upload_fileobj(self, file_obj, object_name, bucket): @STAGE @pytest.mark.case_id("UT-SDK-016") def test_gateway_context_and_final_context_contract() -> None: - from nexent.core.context_runtime.contracts import ContextEvidence, FinalContext + from nexent.core.agents.contracts.context import ContextEvidence, FinalContext from nexent.core.gateway.model_context import EmbeddingContext, LLMContext, ModelContext from nexent.core.gateway.modality.rerank.openai import OpenAICompatibleRerankAdapter from nexent.core.models.openai_llm import OpenAIModel @@ -138,5 +139,3 @@ def test_gateway_context_and_final_context_contract() -> None: assert source["enable_thinking"] is False final = FinalContext(messages=[{"role": "user", "content": "hello"}], evidence=ContextEvidence()) assert final.messages[0]["role"] == "user" and final.tools == [] - - diff --git a/test-e2e/cases/UT-SDK-017/test.py b/test-e2e/cases/UT-SDK-017/test.py index d0fcc199b3..f670f21dc7 100644 --- a/test-e2e/cases/UT-SDK-017/test.py +++ b/test-e2e/cases/UT-SDK-017/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """D1 SDK unit contracts. All I/O clients are fakes owned by the test.""" from __future__ import annotations @@ -91,7 +92,7 @@ def upload_fileobj(self, file_obj, object_name, bucket): @STAGE @pytest.mark.case_id("UT-SDK-017") def test_gateway_rejects_cross_modality_fields_and_unconfigured_runtime() -> None: - from nexent.core.context_runtime.contracts import UnconfiguredContextRuntime + from nexent.core.agents.cores.smolagents.context_contracts import UnconfiguredContextRuntime from nexent.core.gateway.model_context import EmbeddingContext with pytest.raises(TypeError): diff --git a/test-e2e/cases/UT-SDK-018/test.py b/test-e2e/cases/UT-SDK-018/test.py index af3fa2a6b3..8457abbb8e 100644 --- a/test-e2e/cases/UT-SDK-018/test.py +++ b/test-e2e/cases/UT-SDK-018/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """D1 unit contracts for sandboxed skill-script execution (UT-SDK-018). The Docker execution target is a recording stub: no container is started and @@ -79,7 +80,7 @@ def _skill_manager(tmp_path): def _runner(container, *, timeout_seconds: int = 300, workspace: str = "/ws"): - from nexent.core.agents.sandbox import SandboxSkillScriptRunner + from nexent.core.agents.sandbox.execution import SandboxSkillScriptRunner return SandboxSkillScriptRunner( _StubExecutor(container), timeout_seconds=timeout_seconds, workspace_path=workspace, @@ -170,7 +171,7 @@ def spy_resolve(*args, **kwargs): assert bash_exec and bash_exec[0][3] == "bash" # Default mapped timeout is 300 seconds. - from nexent.core.agents.sandbox import SandboxSkillScriptRunner + from nexent.core.agents.sandbox.execution import SandboxSkillScriptRunner default_runner = SandboxSkillScriptRunner(_StubExecutor(_StubContainer())) assert default_runner._timeout_seconds == 300 diff --git a/test-e2e/cases/UT-SDK-019/test.py b/test-e2e/cases/UT-SDK-019/test.py index b5db68daab..f8a461b81b 100644 --- a/test-e2e/cases/UT-SDK-019/test.py +++ b/test-e2e/cases/UT-SDK-019/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """D1 contracts for tags, HITL, model catalog, logging and sandbox cleanup.""" from __future__ import annotations @@ -24,7 +25,7 @@ @STAGE @pytest.mark.case_id("UT-SDK-019") def test_sandbox_tool_bridge_uses_managed_thread_and_closes_it() -> None: - import nexent.core.agents.sandbox as sandbox + import nexent.core.agents.sandbox.execution as sandbox source = Path(sandbox.__file__).read_text(encoding="utf-8") tree = ast.parse(source) diff --git a/test-e2e/cases/UT-SDK-AUTO-35C9D72B4533593E/case.yaml b/test-e2e/cases/UT-SDK-AUTO-35C9D72B4533593E/case.yaml index 999529dbdd..63eab810cd 100644 --- a/test-e2e/cases/UT-SDK-AUTO-35C9D72B4533593E/case.yaml +++ b/test-e2e/cases/UT-SDK-AUTO-35C9D72B4533593E/case.yaml @@ -16,7 +16,7 @@ case: - 本地依赖/组件测试环境可运行;不要求外部模型服务在线。 - 设置 NEXENT_TEST_HOME,目录不位于 Nexent Git 仓库内。 - 当次 run_id 已生成,所有新建资源名称带 run_id;测试基线固定为 develop 当前批次锁定的产品提交;执行时另记录实际被测commit。 - - 可直接导入 sdk/nexent/core/utils/observer.py 的 ProcessType/MessageObserver/Message/ParseTransformer 与 sdk/nexent/core/agents/nexent_agent.py + - 可直接导入 sdk/nexent/core/utils/observer.py 的 ProcessType/MessageObserver/Message/ParseTransformer 与 sdk/nexent/core/agents/cores/smolagents/agent.py 中的调用链,无需外部服务或本地文件资产。 test_data: legacy_asset_declaration: '- 无额外本地文件资产;测试在进程内构造 MessageObserver 实例与含 ANSI 转义序列的字符串 fixture(如 "\x1b[31m红字\x1b[0m"、OSC 序列 diff --git a/test-e2e/cases/UT-SDK-AUTO-74430BEE8438F486/test.py b/test-e2e/cases/UT-SDK-AUTO-74430BEE8438F486/test.py index 3c4fa8bc0d..1c74001030 100644 --- a/test-e2e/cases/UT-SDK-AUTO-74430BEE8438F486/test.py +++ b/test-e2e/cases/UT-SDK-AUTO-74430BEE8438F486/test.py @@ -15,7 +15,8 @@ import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.models.capacity_budget import ( CONTEXT_BUDGET_RESOLVER_VERSION, CallerMaxTokensOverrideForbidden, diff --git a/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/case.yaml b/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/case.yaml index 0491789c86..917e5a1496 100644 --- a/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/case.yaml +++ b/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/case.yaml @@ -22,7 +22,7 @@ case: stub。' steps: - order: 1 - action: 导入被测单元 sdk/nexent/core/models/openai_llm.py 的 OpenAIModel 与 sdk/nexent/core/agents/core_agent.py 的 CoreAgent._provider_overflow_recovery_safe,注入伪造 + action: 导入被测单元 sdk/nexent/core/models/openai_llm.py 的 OpenAIModel 与 sdk/nexent/core/agents/cores/smolagents/core_agent.py 的 CoreAgent._provider_overflow_recovery_safe,注入伪造 chat.completions.create 客户端与内存对象,隔离真实 provider 与网络;不调用真实模型。 expected: 恢复调度:provider 上下文溢出且未收到首 token、context_rebuild 非空时,模型调用 context_rebuild() 一次并以 rebuilt.messages 重新 dispatch;recover_step/recover_final_answer 返回的 FinalContext.messages 成为下一次调用入参,第二次 dispatch 成功返回最终消息。 diff --git a/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/test.py b/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/test.py index 00a6d795bc..5341cc5694 100644 --- a/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/test.py +++ b/test-e2e/cases/UT-SDK-AUTO-85A0FBE8116B9F9A/test.py @@ -5,8 +5,8 @@ import pytest -from nexent.core.agents.core_agent import CoreAgent -from nexent.core.context_runtime.contracts import FinalContext +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.contracts.context import FinalContext from nexent.core.models import openai_llm from nexent.core.models.context_overflow import ( ProviderContextOverflowRetryExhausted, diff --git a/test-e2e/cases/UT-SDK-AUTO-AA4383E2E3B57AD8/test.py b/test-e2e/cases/UT-SDK-AUTO-AA4383E2E3B57AD8/test.py index 27f2e6f2f0..08ecf09291 100644 --- a/test-e2e/cases/UT-SDK-AUTO-AA4383E2E3B57AD8/test.py +++ b/test-e2e/cases/UT-SDK-AUTO-AA4383E2E3B57AD8/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from __future__ import annotations import json @@ -5,7 +6,7 @@ import pytest -from nexent.core.agents.sandbox import _scan_shell_calls +from nexent.core.agents.sandbox.execution import _scan_shell_calls from nexent.core.tools.run_skill_script_tool import ( RunSkillScriptTool, SkillScriptExecutionError, diff --git a/test-e2e/cases/UT-SDK-AUTO-E6DDFB4661E0D8B4/test.py b/test-e2e/cases/UT-SDK-AUTO-E6DDFB4661E0D8B4/test.py index db51a77321..2b6ad8968c 100644 --- a/test-e2e/cases/UT-SDK-AUTO-E6DDFB4661E0D8B4/test.py +++ b/test-e2e/cases/UT-SDK-AUTO-E6DDFB4661E0D8B4/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from __future__ import annotations import logging @@ -5,7 +6,7 @@ import pytest -from nexent.core.agents.managed_mcp import ManagedMCPToolCollection +from nexent.core.agents.resources.mcp import ManagedMCPToolCollection from nexent.consts.mcp_errors import MCPConnectionTimeoutError diff --git a/test-e2e/cases/UT-SDK-AUTO-ED416510CCE29002/test.py b/test-e2e/cases/UT-SDK-AUTO-ED416510CCE29002/test.py index f570186396..078a10d1da 100644 --- a/test-e2e/cases/UT-SDK-AUTO-ED416510CCE29002/test.py +++ b/test-e2e/cases/UT-SDK-AUTO-ED416510CCE29002/test.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from __future__ import annotations import asyncio @@ -7,7 +8,7 @@ import pytest -from nexent.core.agents.a2a_agent_proxy import A2AAgentInfo, ExternalA2AAgentProxy +from nexent.core.agents.resources.a2a import A2AAgentInfo, ExternalA2AAgentProxy class FakeCancellationScope: diff --git a/test-e2e/changes/refactors/SDK-AGENT-CANONICAL-DEPLOY-20261029.yaml b/test-e2e/changes/refactors/SDK-AGENT-CANONICAL-DEPLOY-20261029.yaml new file mode 100644 index 0000000000..4b976c5b49 --- /dev/null +++ b/test-e2e/changes/refactors/SDK-AGENT-CANONICAL-DEPLOY-20261029.yaml @@ -0,0 +1,49 @@ +schema_version: '1.0' +change: + change_id: SDK-AGENT-CANONICAL-DEPLOY-20261029 + change_type: refactor + title: Deploy canonical Agent SDK paths and verify real assistant journeys + description: Rebuild and deploy the current refactored source, verify absence of all + removed Agent alias files in source and installed SDK across backend roles, and + exercise existing real model resource and browser journeys. Preserve source assistants, + history, model directories and all existing runtime contracts. + feature_contract_changed: false + no_case_change_reason: Existing image continuity, Skills, knowledge retrieval, + mixed Core children and browser history contracts already cover this deployment. + Reuse fixed scripts; the resource diagnostic selection does not certify the full + external MCP Case, whose existing environment blocker remains recorded. + affected_features: + existing: + - JIUWEN-RESOURCES + - JIUWEN-CONTEXT + - JIUWEN-OUTPUT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-REAL-UI-D4-001 + - JIUWEN-SKILL-FILES-D4-001 + - JIUWEN-RESOURCES-D3-003 + modified: [] + added: [] + retired: [] + traditional_ut: + decision: reuse + behaviors: + - Canonical imports own the unique contracts factory Core adapters and sandbox configuration. + - Removed modules and legacy Python pickle globals remain unavailable. + - Context and resource boundaries retain original isolation and error semantics. + reason: Product behavior is unchanged from the verified canonical import migration. + Reuse its focused boundary UT and prove installed file sets and actual downstream + business outcomes in deployed images rather than adding duplicate unit tests. + test_selectors: + - test/sdk/core/agents/test_canonical_agent_imports.py + - test/sdk/core/agents/test_context_canonical_imports.py + - test/sdk/core/agents/test_sandbox_directory_boundaries.py + - test/sdk/core/agents/test_context_directory_boundaries.py + - test/sdk/core/agents/test_resource_orchestration_boundaries.py + notes: Deployment is explicitly authorized by the user. Use a clean source context, + preserve image rollback tags and existing full sandbox selection, and do not delete + data volumes or modify source assistants. Global generated-asset validation retains + the pre-existing missing F-111-RULE-02 blocker. No commit or push is requested. diff --git a/test-e2e/changes/refactors/SDK-AGENT-CANONICAL-IMPORTS-20261029.yaml b/test-e2e/changes/refactors/SDK-AGENT-CANONICAL-IMPORTS-20261029.yaml new file mode 100644 index 0000000000..e38fdfcb74 --- /dev/null +++ b/test-e2e/changes/refactors/SDK-AGENT-CANONICAL-IMPORTS-20261029.yaml @@ -0,0 +1,94 @@ +schema_version: '1.0' +change: + change_id: SDK-AGENT-CANONICAL-IMPORTS-20261029 + change_type: refactor + title: Migrate repository callers and remove historical Agent import facades + description: Follow explicit user authorization to remove old Agent SDK import paths, split composite imports + by canonical owner and migrate runtime patch/file-loading sites. Preserve CM algorithms, resource ownership, + HTTP/SSE/history formats and frozen models. + feature_contract_changed: false + no_case_change_reason: This deliberately breaks Python import compatibility only. Existing runtime resource context + output and cancellation contracts remain valid; fixed scripts update imports and source bindings without weakening + business assertions. + affected_features: + existing: + - JIUWEN-MODEL-BRIDGE + - JIUWEN-RESOURCES + - JIUWEN-CONTEXT + - JIUWEN-OUTPUT + - SAI-001 + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D5-001 + - JIUWEN-OUTPUT-D3-001 + modified: + - AGT-AUTO-01804EF17A1DF51B + - CMSR-D1-003 + - CMSR-D1-004 + - FAF-D1-001 + - JIUWEN-CONTEXT-D3-001 + - JIUWEN-CONTEXT-D5-001 + - JIUWEN-MODEL-BRIDGE-D3-001 + - JIUWEN-RECOVERY-D5-001 + - JIUWEN-RELEASE-D3-001 + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D3-002 + - REL-AUTO-1FAFCA5A681580F5 + - SAI-D1-001 + - SAI-D1-002 + - SAI-D1-005 + - SAI-D3-001 + - SEARCH-DEEPSEEK-D3-001 + - TFP-D1-001 + - TFP-D1-004 + - TFP-D1-005 + - TFP-D1-006 + - TFP-D5-001 + - UT-BE-AUTO-4781AB28D8956C3F + - UT-BE-AUTO-F2215F8782D68127 + - UT-SDK-016 + - UT-SDK-017 + - UT-SDK-018 + - UT-SDK-019 + - UT-SDK-AUTO-35C9D72B4533593E + - UT-SDK-AUTO-74430BEE8438F486 + - UT-SDK-AUTO-85A0FBE8116B9F9A + - UT-SDK-AUTO-AA4383E2E3B57AD8 + - UT-SDK-AUTO-E6DDFB4661E0D8B4 + - UT-SDK-AUTO-ED416510CCE29002 + added: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Canonical factory contracts Core adapters and sandbox submodules import without historical facades. + - Removed Python paths and historical pickle globals no longer resolve in a clean process or wheel. + - Configuration imports remain lightweight and optional Jiuwen failures do not fall back to another Core. + - JSON and canonical type serialization, CM history budgets and resource lifecycle remain unchanged. + - Every internal import dynamic lookup mock patch and file loader uses its actual canonical owner. + - Models and model-service compatibility directories retain their exact file sets and bytes. + reason: Replace obsolete compatibility-only assertions with canonical and removal checks, retain all business + UT and formal D1 assets, and reuse existing runtime D3/D5. + test_selectors: + - test/sdk/core/agents/test_canonical_agent_imports.py + - test/sdk/core/agents/test_context_canonical_imports.py + - test/sdk/core/agents/test_sandbox_directory_boundaries.py + - test/sdk/core/agents/test_context_directory_boundaries.py + - test/sdk/core/agents/test_resource_orchestration_boundaries.py + - test/sdk/core/agents/test_agent_model.py + - test/sdk/core/agents/test_core_agent.py + - test/sdk/core/agents/test_core_agent_planning.py + - test/sdk/core/agents/test_nexent_agent.py + - test/sdk/core/agents/test_run_agent.py + - test/sdk/core/agents/test_sandbox.py + - test/backend/agents/test_create_agent_info.py + - test/backend/services/test_agent_service.py + notes: User instruction on 2026-10-29 explicitly supersedes the R1 to R4 old-import retention contract. No external + consumer compatibility or old Python pickle support is promised. Library-specific execution compatibility modules + and the unchanged legacy memory API algorithm seam are not import-alias files and remain. No deployment is requested. + Historical R1 to R4 UT selector bindings now point to the replacement canonical/removal tests; their original + execution evidence remains historical. Other listed Case scripts receive import/source binding updates only; + execution is claimed only for the eight selected Jiuwen and SAI Cases recorded in SPEC section 18. diff --git a/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R1-20261027.yaml b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R1-20261027.yaml new file mode 100644 index 0000000000..c53abf518e --- /dev/null +++ b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R1-20261027.yaml @@ -0,0 +1,55 @@ +schema_version: '1.0' +change: + change_id: SDK-AGENT-DIRECTORY-R1-20261027 + change_type: refactor + title: Relocate Agent contracts factory and Core adapters without changing execution + description: Move canonical Agent contracts, factory, child dispatch and Core-specific adapters; retain old module + aliases and object identity. Model directories and R2 resource extraction are excluded. + feature_contract_changed: false + no_case_change_reason: Existing runtime contracts remain unchanged. The model bridge script's stale exact tool + list is synchronized to the established controlled native Python tool, with the same failure reproduced against + the pre-refactor SDK. No new acceptance contract or duplicate D1 is needed. + affected_features: + existing: + - JIUWEN-MODEL-BRIDGE + - JIUWEN-RESOURCES + - JIUWEN-OUTPUT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-OUTPUT-D3-001 + modified: + - JIUWEN-MODEL-BRIDGE-D3-001 + added: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Old and canonical modules share types, exceptions, registry and monkeypatch lookup sites. + - Factory registration and lazy creation preserve success and invalid, duplicate and missing-Core failures. + - Resource, model and output behavior remains unchanged through old and canonical imports. + - Public contracts and factory import without loading optional Jiuwen modules; built wheel contains both paths. + reason: Existing unit suites prove behavior but cannot detect duplicate canonical types or registries; subprocess + and identity assertions cover these new migration boundaries without a new formal D1. + test_selectors: + - test/sdk/core/agents/test_canonical_agent_imports.py + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/agents/test_jiuwen_resources.py + - test/sdk/core/agents/test_jiuwen_output.py + - test/sdk/core/agents/test_jiuwen_answer_boundary.py + - test/sdk/core/agents/test_run_agent.py + - test/sdk/core/agents/test_subagent_invocation_wrapper.py + - test/sdk/core/agents/test_subagent_resource_ownership.py + - test/backend/utils/test_runtime_output.py + - test/backend/utils/test_runtime_history_projection.py + notes: runtime-agent-core-factory/sdk-directory-refactor.md R1; product baseline f1d515ffc. AgentConfig/run-info + splitting, NexentAgent resource extraction and executor callback removal remain R2. core/models and model-interface + compatibility directories remain unchanged. Existing formal Cases are reused. The original model bridge Case + failed both before and after migration because it expected only add, despite the previously delivered nexent_execute_python. + Keep an exact two-tool allowlist, not a subset check. On 2026-10-29, obsolete compatibility UT file selectors + were rebound to canonical/removal tests by SDK-AGENT-CANONICAL-IMPORTS-20261029. Original old-import execution + claims describe this historical stage only. diff --git a/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R2-20261027.yaml b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R2-20261027.yaml new file mode 100644 index 0000000000..8ce1f596fa --- /dev/null +++ b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R2-20261027.yaml @@ -0,0 +1,59 @@ +schema_version: '1.0' +change: + change_id: SDK-AGENT-DIRECTORY-R2-20261027 + change_type: refactor + title: Separate common Agent orchestration resources and smolagents execution + description: Extract common resource ownership and task orchestration, route Jiuwen to the shared provider and + collect the smolagents implementation behind its Adapter. + feature_contract_changed: false + no_case_change_reason: Existing model, resource, collaboration, output and cancellation contracts are retained. + Architecture and partial-preparation ownership need traditional UT; runtime Cases are reused. + affected_features: + existing: + - JIUWEN-MODEL-BRIDGE + - JIUWEN-RESOURCES + - JIUWEN-OUTPUT + - JIUWEN-CONTEXT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-MODEL-BRIDGE-D3-001 + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-OUTPUT-D3-001 + - JIUWEN-RESOURCES-D5-001 + - JIUWEN-CONTEXT-D3-001 + modified: [] + added: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Common preparation and orchestration never construct a smolagents agent for Jiuwen. + - Main and child invocation use the same Adapter lifecycle without a production executor callback. + - Tool parameters, user identity, Skills authorization, workspace and file results remain isolated. + - Partial preparation, completion, errors and cancellation release owned resources exactly once. + - Old imports preserve canonical types while models and model-interface compatibility stay frozen. + reason: Existing execution suites prove retained behavior; new ownership and dependency assertions prove the + extraction without duplicating the runtime Cases as D1. + test_selectors: + - test/sdk/core/agents/test_resource_orchestration_boundaries.py + - test/sdk/core/agents/test_canonical_agent_imports.py + - test/sdk/core/agents/test_nexent_agent.py + - test/sdk/core/agents/test_run_agent.py + - test/sdk/core/agents/test_core_agent.py + - test/sdk/core/agents/test_core_agent_planning.py + - test/sdk/core/agents/test_nexent_agent_sandbox_construction.py + - test/sdk/core/agents/test_subagent_resource_ownership.py + - test/sdk/core/agents/test_a2a_agent_proxy.py + - test/sdk/core/agents/test_a2a_active_cancellation.py + - test/backend/agents/test_create_agent_info.py + notes: Product baseline is f1d515ffc plus the uncommitted R1 worktree. SDK model directories remain untouched. + No production deployment or provider version upgrade. Fixed scripts retain assertions and patch canonical tool + registries. The cancellation helper accepts the documented stream CancelledError only for the explicit cancellation + scenario, waits for the worker, and still verifies stopped outcome, exact call counts, no side effects and zero + active owners. On 2026-10-29, obsolete compatibility UT file selectors were rebound to canonical/removal tests + by SDK-AGENT-CANONICAL-IMPORTS-20261029. Original old-import execution claims describe this historical stage + only. diff --git a/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R3-20261028.yaml b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R3-20261028.yaml new file mode 100644 index 0000000000..a86d71d367 --- /dev/null +++ b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R3-20261028.yaml @@ -0,0 +1,71 @@ +schema_version: '1.0' +change: + change_id: SDK-AGENT-DIRECTORY-R3-20261028 + change_type: refactor + title: Separate neutral context contracts Core memory bridges and sandbox modules + description: Preserve CM selection budgets summaries and history while moving native memory projection and message + conversion behind explicit Core adapters. Split sandbox configuration, execution TLS and library compatibility + without changing isolation or resource ownership. + feature_contract_changed: false + no_case_change_reason: Existing context history resource output and cancellation acceptance contracts remain valid. + Import identity dependency and packaging gaps are traditional UT; existing controlled integration and reliability + Cases are reused without a new D1. + affected_features: + existing: + - JIUWEN-CONTEXT + - JIUWEN-RESOURCES + - JIUWEN-MODEL-BRIDGE + - JIUWEN-OUTPUT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-CONTEXT-D3-001 + - JIUWEN-CONTEXT-D5-001 + - JIUWEN-MODEL-BRIDGE-D3-001 + - JIUWEN-RESOURCES-D3-001 + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-RESOURCES-D5-001 + - JIUWEN-OUTPUT-D3-001 + modified: [] + added: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Public input evidence final context and clarification types retain identity validation and serialization. + - Neutral CM algorithms do not import native memory or message classes; Core bridges preserve ordered payloads. + - Legacy memory entry points remain explicit compatibility facades and are not used by Jiuwen. + - CM configuration budgets summaries fingerprints and persistence failure behavior remain unchanged. + - Sandbox configuration execution TLS bootstrap and optional library seams preserve old imports and patch targets. + - Sandbox cancellation partial setup leases tool bridging and TLS trust retain original ownership and isolation. + - Models and model-service compatibility files retain exactly the baseline hashes. + reason: Existing CM sandbox and Core tests prove retained algorithms and errors. New boundary, compatibility + and installed-wheel assertions address the directory refactor without duplicating D1. + test_selectors: + - test/sdk/core/agents/test_context_directory_boundaries.py + - test/sdk/core/agents/test_sandbox_directory_boundaries.py + - test/sdk/core/agents/test_context_manager_assembly.py + - test/sdk/core/agents/test_history_context_runtime.py + - test/sdk/core/context_runtime/test_runtimes.py + - test/sdk/core/agents/test_context_input.py + - test/sdk/core/agents/test_context_import_isolation.py + - test/sdk/core/agents/test_context_canonical_imports.py + - test/sdk/core/agents/context/test_long_term_memory_selector.py + - test/sdk/core/agents/test_sandbox.py + - test/sdk/core/agents/test_sandbox_tls.py + - test/sdk/core/agents/test_sandbox_tls_files.py + - test/sdk/core/agents/test_sandbox_workspace.py + - test/sdk/core/agents/test_sandbox_lease_cleanup.py + - test/sdk/core/agents/test_sandbox_channel_readiness.py + - test/sdk/core/agents/test_sandbox_tool_bridge_lifecycle.py + - test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py + - test/sdk/core/agents/test_core_agent.py + - test/sdk/core/agents/test_core_agent_planning.py + - test/sdk/core/agents/test_jiuwen_context.py + - test/backend/agents/test_create_agent_info.py + notes: Baseline is f1d515ffc plus the uncommitted R1 and R2 worktree. No model migration, dependency upgrade environment + change production deployment or permission expansion is in scope. On 2026-10-29, obsolete compatibility UT file + selectors were rebound to canonical/removal tests by SDK-AGENT-CANONICAL-IMPORTS-20261029. Original old-import + execution claims describe this historical stage only. diff --git a/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R4-20261028.yaml b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R4-20261028.yaml new file mode 100644 index 0000000000..1227697544 --- /dev/null +++ b/test-e2e/changes/refactors/SDK-AGENT-DIRECTORY-R4-20261028.yaml @@ -0,0 +1,53 @@ +schema_version: '1.0' +change: + change_id: SDK-AGENT-DIRECTORY-R4-20261028 + change_type: refactor + title: Verify clean SDK image installation and deployed real Agent journeys + description: Build the R1 to R3 source with clean SDK installation, audit installed canonical and compatibility + modules, retain rollback images and verify real resource execution, history continuity, Core switching and common + UI rendering. + feature_contract_changed: false + no_case_change_reason: Existing real resource and browser journey contracts cover the required deployed behavior. + Packaging and import identity reuse the R1 to R3 unit checks and a separate installed-source audit; no duplicate + D1 is necessary. + affected_features: + existing: + - JIUWEN-RESOURCES + - JIUWEN-OUTPUT + - JIUWEN-CONTEXT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D5-001 + modified: + - JIUWEN-RESOURCES-D3-003 + - JIUWEN-REAL-UI-D4-001 + - JIUWEN-SKILL-FILES-D4-001 + added: [] + retired: [] + traditional_ut: + decision: reuse + behaviors: + - Canonical and historical imports retain module and type identity. + - Sandbox is a package with lightweight configuration and correct TLS bootstrap resources. + - Neutral context and Core memory boundaries retain saved CM and history semantics. + - Terminal MCP failure prevents subsequent model requests. + reason: R1 to R3 already added focused success boundary and failure assertions. R4 reruns these unchanged and + adds actual image installation and real journey evidence. + test_selectors: + - test/sdk/core/agents/test_canonical_agent_imports.py + - test/sdk/core/agents/test_resource_orchestration_boundaries.py + - test/sdk/core/agents/test_context_directory_boundaries.py + - test/sdk/core/agents/test_sandbox_directory_boundaries.py + - test/sdk/core/agents/test_jiuwen_context_terminal_guard.py + notes: The original external date probe and full Case acceptance remain intact. Current tenant external providers + are unavailable. A reachable PubMed candidate belongs to another tenant and is not an acceptable substitute; + record the real MCP proof as blocked rather than widening access or accepting a Mock. Fixed real fixtures import + the current gateway authentication owner and use the current complete prompt field. The browser runner accepts + an explicit machine-local installed channel when downloaded Chromium cannot start; all journey contracts and + assertions remain unchanged. Retain thin historical import facades per the compatibility contract. Do not remove + aliases or modify model directories, dependencies, source agents or unrelated assets. On 2026-10-29, obsolete + compatibility UT file selectors were rebound to canonical/removal tests by SDK-AGENT-CANONICAL-IMPORTS-20261029. + Original old-import execution claims describe this historical stage only. diff --git a/test-e2e/infra/automation/jiuwen_bridge_runtime.py b/test-e2e/infra/automation/jiuwen_bridge_runtime.py index a6b5de8a99..017ce9fa27 100644 --- a/test-e2e/infra/automation/jiuwen_bridge_runtime.py +++ b/test-e2e/infra/automation/jiuwen_bridge_runtime.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Real factory/worker/transport collaboration shared by the two runtime Cases.""" import asyncio @@ -7,11 +9,12 @@ from unittest.mock import Mock from jiuwen_bridge_fixture import profile_for, provider -from nexent.core.agents import nexent_agent -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig, ToolConfig -from nexent.core.agents.core_agent import CoreAgent -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.run_agent import agent_run +import nexent.core.tools as nexent_agent +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver @@ -22,6 +25,8 @@ def forbidden(*args, **kwargs): monkeypatch.setattr("openjiuwen.core.foundation.llm.model.create_model_client", forbidden) monkeypatch.setattr(CoreAgent, "__init__", forbidden) + from nexent.core.agents.cores.smolagents.agent import NexentAgent + monkeypatch.setattr(NexentAgent, "__init__", forbidden) # Keep real model transport and observer events, but isolate the external monitoring sink. monitoring = Mock() monitoring.trace_llm_request.side_effect = lambda *args, **kwargs: nullcontext(None) diff --git a/test-e2e/infra/automation/jiuwen_collaboration_runtime.py b/test-e2e/infra/automation/jiuwen_collaboration_runtime.py index 1b10b8bf54..b1b7cebf7d 100644 --- a/test-e2e/infra/automation/jiuwen_collaboration_runtime.py +++ b/test-e2e/infra/automation/jiuwen_collaboration_runtime.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Real model/A2A HTTP fixtures for configured mixed-Core invocations.""" import asyncio @@ -7,16 +9,11 @@ from unittest.mock import Mock from jiuwen_bridge_fixture import profile_for -from nexent.core.agents.agent_model import ( - AgentConfig, - AgentRunInfo, - ExternalA2AAgentConfig, - ModelConfig, - ToolConfig, -) -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.core_adapters.smolagents import SmolagentsAdapter -from nexent.core.agents.run_agent import agent_run +from nexent.core.agents.contracts.config import AgentConfig, ExternalA2AAgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.cores.smolagents.adapter import SmolagentsAdapter +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver @@ -137,7 +134,8 @@ def prepare(adapter): cards.extend(card.id for card in adapter.native_agent.ability_manager.list()) monkeypatch.setattr(JiuwenAdapter, "prepare", prepare) async with collaboration_provider(parent_core, child_core, protocol, remote_protocol, fault) as (url, state): - from nexent.core.agents import nexent_agent + from nexent.core.agents.cores.smolagents import agent as nexent_agent + import nexent.core.tools as shared_tool_types from smolagents import Tool class ChildEchoTool(Tool): @@ -155,7 +153,9 @@ def forward(self): raise AssertionError("Child cannot execute parent resources") monkeypatch.setattr(nexent_agent, "ChildEchoTool", ChildEchoTool, raising=False) + monkeypatch.setattr(shared_tool_types, "ChildEchoTool", ChildEchoTool, raising=False) monkeypatch.setattr(nexent_agent, "ParentOnlyTool", ParentOnlyTool, raising=False) + monkeypatch.setattr(shared_tool_types, "ParentOnlyTool", ParentOnlyTool, raising=False) def model(path, temperature, credential, outlet): profile = profile_for(url + path, protocol) @@ -189,7 +189,7 @@ def model(path, temperature, credential, outlet): name: LanePolicy(name=name, max_workers=2 if name == "sandbox" and fault != "capacity" else 1, max_queue_size=8) for name in ("agent-run", "model-tool-io", "mcp-session", "background-service", "sandbox", "control-io")}) manager.start() - info = AgentRunInfo(query="PARENT_TASK_ONLY", agent_config=root, + info = AgentRunInfo(query="Call the subagent delegate. PARENT_TASK_ONLY", agent_config=root, model_config_list=[model("/parent", 0.9, "parent-only", 7)], observer=MessageObserver(lang="en"), stop_event=Event(), thread_manager=manager, collaboration_timeout_seconds=0.4 if fault == "capacity" else 5, tenant_id="spike-tenant", user_context={"user_id": "trusted"}) @@ -198,13 +198,24 @@ async def cancel(): await asyncio.sleep(0.01) info.stop_event.set() canceller = asyncio.create_task(cancel()) if fault == "cancel" else None + output = [] + cancelled = False try: - output = [json.loads(event) async for event in agent_run(info, thread_manager=manager)] + async for event in agent_run(info, thread_manager=manager): + output.append(json.loads(event)) + except asyncio.CancelledError: + # The public stream stops immediately on user cancellation. Inspect + # only the real owned observer after the worker has drained below. + if fault not in {"cancel", "capacity", "timeout"} or not info.stop_event.is_set(): + raise + cancelled = True finally: if canceller: await canceller drained = await manager.shutdown(timeout=5) assert not drained.stuck_execution_ids + if cancelled: + output.extend(json.loads(event) for event in info.observer.get_cached_message()) for _ in range(50): if not fault or state["closed"]: break diff --git a/test-e2e/infra/automation/jiuwen_context_runtime.py b/test-e2e/infra/automation/jiuwen_context_runtime.py index ebc6333178..4422da1173 100644 --- a/test-e2e/infra/automation/jiuwen_context_runtime.py +++ b/test-e2e/infra/automation/jiuwen_context_runtime.py @@ -17,14 +17,10 @@ from unittest.mock import Mock from jiuwen_bridge_fixture import profile_for -from nexent.core.agents.agent_model import ( - AgentConfig, - AgentRunInfo, - ModelConfig, - ToolConfig, -) +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.agents.context import ContextManagerConfig -from nexent.core.agents.run_agent import agent_run +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver from sqlalchemy import create_engine, insert, select, text @@ -151,7 +147,8 @@ async def run_context_scenario(monkeypatch, engine, original="smolagents", proto get_historical_context, ) from management.db.models import ConversationMessageUnit - from nexent.core.agents import nexent_agent + from nexent.core.agents.cores.smolagents import agent as nexent_agent + import nexent.core.tools as shared_tool_types from management.services.conversation.service import ( persist_history_summary_candidate, ) @@ -240,6 +237,7 @@ def forward(self): effects.append("once") return "TOOL_RESULT_ALPHA" monkeypatch.setattr(nexent_agent, "RememberTool", RememberTool, raising=False) + monkeypatch.setattr(shared_tool_types, "RememberTool", RememberTool, raising=False) monitoring = Mock() monitoring.trace_llm_request.side_effect = lambda *a, **k: nullcontext(None) monkeypatch.setattr("nexent.core.model_service.service.get_monitoring_manager", lambda: monitoring) @@ -310,8 +308,16 @@ async def cancel_summary(): await asyncio.sleep(0.01) raise AssertionError("Summary did not start") canceller = asyncio.create_task(cancel_summary()) if fault == "cancel_summary" else None + output = [] try: - output = [json.loads(chunk) async for chunk in agent_run(info, thread_manager=manager)] + try: + async for chunk in agent_run(info, thread_manager=manager): + output.append(json.loads(chunk)) + except asyncio.CancelledError: + if fault != "cancel_summary" or not info.stop_event.is_set(): + raise + await asyncio.wait_for(asyncio.wrap_future(info.thread_future), timeout=5) + output.extend(json.loads(chunk) for chunk in info.observer.get_cached_message()) finally: if canceller: await canceller diff --git a/test-e2e/infra/automation/jiuwen_resource_runtime.py b/test-e2e/infra/automation/jiuwen_resource_runtime.py index 5c0b4591b1..c02c9fbec5 100644 --- a/test-e2e/infra/automation/jiuwen_resource_runtime.py +++ b/test-e2e/infra/automation/jiuwen_resource_runtime.py @@ -20,11 +20,12 @@ sys.path.append(str(Path(httpx.__file__).resolve().parents[1] / folder)) from jiuwen_bridge_fixture import profile_for -from nexent.core.agents import nexent_agent -from nexent.core.agents.agent_model import AgentConfig, AgentHistory, AgentRunInfo, ModelConfig, ToolConfig -from nexent.core.agents.core_agent import CoreAgent -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.run_agent import agent_run +import nexent.core.tools as nexent_agent +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver @@ -136,7 +137,7 @@ def track_close(adapter): "isolated_skills_root": True} sandbox = None if docker_sandbox: - from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel + from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel parameters["workspace_path"] = str(tmp_path / "run") sandbox = SandboxConfig(level=SandboxLevel.DOCKER, docker_image="nexent/nexent-sandbox-full:latest", @@ -169,7 +170,16 @@ def track_close(adapter): model_name="fixture", max_steps=6, tools=tools), ) async def consume(run_info): - return [json.loads(event) async for event in agent_run(run_info, thread_manager=manager)] + events = [] + try: + async for event in agent_run(run_info, thread_manager=manager): + events.append(json.loads(event)) + except asyncio.CancelledError: + if scenario != "cancel" or not run_info.stop_event.is_set(): + raise + await asyncio.wait_for(asyncio.wrap_future(run_info.thread_future), timeout=5) + events.extend(json.loads(event) for event in run_info.observer.get_cached_message()) + return events async def cancel_when_mcp_started(): async with httpx.AsyncClient(trust_env=False) as client: diff --git a/test-e2e/infra/automation/model_controls/api.py b/test-e2e/infra/automation/model_controls/api.py index e55b219f2f..2a8bd87ee6 100644 --- a/test-e2e/infra/automation/model_controls/api.py +++ b/test-e2e/infra/automation/model_controls/api.py @@ -15,7 +15,7 @@ from management.services.model import run_bindings from contracts.errors.exceptions import ForbiddenError from management.services.model import run_controls -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig import permissions.depends as depends from permissions import rbac from permissions.models import CurrentUser diff --git a/test-e2e/infra/automation/shared/sai_component.py b/test-e2e/infra/automation/shared/sai_component.py index cc6d00acf3..8903035948 100644 --- a/test-e2e/infra/automation/shared/sai_component.py +++ b/test-e2e/infra/automation/shared/sai_component.py @@ -1,11 +1,12 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """SAI-D1 contracts for isolated invocation factories and ownership.""" import asyncio from types import SimpleNamespace import pytest -from nexent.core.agents import nexent_agent as factory_module -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.cores.smolagents import agent as factory_module +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.concurrency.cancellation import RunCancellationScope from nexent.core.concurrency.context import ( diff --git a/test-e2e/infra/automation/shared/sai_docker.py b/test-e2e/infra/automation/shared/sai_docker.py index 4dd2b50411..f23d807e74 100644 --- a/test-e2e/infra/automation/shared/sai_docker.py +++ b/test-e2e/infra/automation/shared/sai_docker.py @@ -12,10 +12,15 @@ from pathlib import Path MODULES = { - "nexent.core.agents.nexent_agent": "core/agents/nexent_agent.py", - "nexent.core.agents.agent_model": "core/agents/agent_model.py", - "nexent.core.agents.subagent_wrapper": "core/agents/subagent_wrapper.py", - "nexent.core.agents.sandbox": "core/agents/sandbox.py", + "nexent.core.agents.cores.smolagents.agent": "core/agents/cores/smolagents/agent.py", + "nexent.core.agents.contracts.config": "core/agents/contracts/config.py", + "nexent.core.agents.contracts.run": "core/agents/contracts/run.py", + "nexent.core.agents.resources.subagents": "core/agents/resources/subagents.py", + "nexent.core.agents.sandbox": "core/agents/sandbox/__init__.py", + "nexent.core.agents.sandbox.execution": "core/agents/sandbox/execution.py", + "nexent.core.agents.sandbox.config": "core/agents/sandbox/config.py", + "nexent.core.agents.sandbox.compatibility": "core/agents/sandbox/compatibility.py", + "nexent.core.agents.sandbox.tls": "core/agents/sandbox/tls.py", "nexent.core.concurrency.cancellation": "core/concurrency/cancellation.py", } @@ -68,6 +73,9 @@ def verification_image(repo: Path): definition = Path(scratch) / "Dockerfile" definition.write_text( "FROM nexent/nexent:latest\n" + "RUN /opt/backend/.venv/bin/python -c \"from pathlib import Path; import shutil; " + "p=Path('/opt/backend/.venv/lib/python3.11/site-packages/nexent').resolve(); " + "assert p.name=='nexent' and p.parent.name=='site-packages'; shutil.rmtree(p)\"\n" "COPY nexent/ /opt/backend/.venv/lib/python3.11/site-packages/nexent/\n" f"LABEL nexent.sai.verification={uuid.uuid4().hex}\n", encoding="utf-8", diff --git a/test-e2e/infra/automation/shared/sai_kernel_probe.py b/test-e2e/infra/automation/shared/sai_kernel_probe.py index 1f0b02f511..2256c7d384 100644 --- a/test-e2e/infra/automation/shared/sai_kernel_probe.py +++ b/test-e2e/infra/automation/shared/sai_kernel_probe.py @@ -12,10 +12,15 @@ import threading from pathlib import Path -from nexent.core.agents import agent_model, nexent_agent, sandbox, subagent_wrapper -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel, SandboxScope +import nexent.core.agents.sandbox as sandbox_package +from nexent.core.agents.contracts import config as agent_configuration, run as agent_running +from nexent.core.agents.cores.smolagents import agent as nexent_agent +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.resources import subagents as subagent_wrapper +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel, SandboxScope +from nexent.core.agents.sandbox import execution, config, compatibility, tls from nexent.core.concurrency import LanePolicy, ThreadManager, cancellation from nexent.core.concurrency.cancellation import RunCancellationScope from nexent.core.concurrency.context import ( @@ -80,7 +85,8 @@ def create_single_agent(self, config, **kwargs): fingerprints = { module.__name__: hashlib.sha256(Path(inspect.getfile(module)).read_bytes()).hexdigest() - for module in (nexent_agent, sandbox, subagent_wrapper, agent_model, cancellation) + for module in (nexent_agent, sandbox_package, execution, config, compatibility, tls, subagent_wrapper, + agent_configuration, agent_running, cancellation) } if expected_sources: assert fingerprints == expected_sources, 'Image source differs from worktree' diff --git a/test-e2e/infra/automation/shared/tool_fastpath.py b/test-e2e/infra/automation/shared/tool_fastpath.py index c46c923411..7a203507b8 100644 --- a/test-e2e/infra/automation/shared/tool_fastpath.py +++ b/test-e2e/infra/automation/shared/tool_fastpath.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Controlled executors and official-tool fixtures shared by fast-path Cases.""" from __future__ import annotations @@ -7,9 +8,7 @@ from smolagents.models import ChatMessage, MessageRole from smolagents.monitoring import TokenUsage -from nexent.core.agents.tool_fastpath import ( - DeferredToolExecutor, -) +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool diff --git a/test-e2e/infra/automation/shared/tool_fastpath_aidp.py b/test-e2e/infra/automation/shared/tool_fastpath_aidp.py index 77bdcbae96..4dc3a35012 100644 --- a/test-e2e/infra/automation/shared/tool_fastpath_aidp.py +++ b/test-e2e/infra/automation/shared/tool_fastpath_aidp.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """AIDP factory fixture with controlled HTTP transport and resource cleanup.""" from __future__ import annotations @@ -10,9 +11,9 @@ import httpx import pytest -from nexent.core.agents.agent_model import AgentConfig, ToolConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel, SandboxScope +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel, SandboxScope from nexent.core.utils.observer import MessageObserver from shared.tool_fastpath import FinalEnvelopeModel, OrdinaryExecutor @@ -20,7 +21,7 @@ @pytest.fixture def aidp_runtime(monkeypatch): """Use a real factory/tool with a controlled HTTP transport and no provider I/O.""" - import nexent.core.agents.sandbox as sandbox_module + import nexent.core.agents.sandbox.execution as sandbox_module import nexent.core.ext_components.aidp.aidp_search_tool as aidp_module requests, events, acquisitions, releases = [], [], [], [] diff --git a/test-e2e/infra/automation/shared/tool_fastpath_observation.py b/test-e2e/infra/automation/shared/tool_fastpath_observation.py index 483aa20f3c..e51efd02b5 100644 --- a/test-e2e/infra/automation/shared/tool_fastpath_observation.py +++ b/test-e2e/infra/automation/shared/tool_fastpath_observation.py @@ -14,7 +14,12 @@ from shared.config import load_secret_env, repo_root -SOURCES = ("core/agents/nexent_agent.py", "core/agents/tool_fastpath.py", "core/agents/sandbox.py") +SOURCES = ( + "core/agents/cores/smolagents/agent.py", "core/agents/cores/smolagents/tool_fastpath.py", + "core/agents/resources/workspace.py", "core/agents/sandbox/__init__.py", + "core/agents/sandbox/config.py", "core/agents/sandbox/execution.py", + "core/agents/sandbox/compatibility.py", "core/agents/sandbox/tls.py", +) def docker_command(*arguments): @@ -42,7 +47,7 @@ def verify_deployment(cfg): script = ( "import hashlib,json; from pathlib import Path; import nexent; " "from contracts.constants.environment import AGENT_WORKSPACE_ROOT,NEXENT_SANDBOX_CONTAINER_WORKSPACE_ROOT; " - "from nexent.core.agents.sandbox_workspace import SandboxWorkspace; " + "from nexent.core.agents.resources.workspace import SandboxWorkspace; " "root=Path(nexent.__file__).parent; " f"sources={{p:hashlib.sha256((root/p).read_bytes()).hexdigest() for p in {SOURCES!r}}}; " "mapping=SandboxWorkspace(Path(AGENT_WORKSPACE_ROOT),NEXENT_SANDBOX_CONTAINER_WORKSPACE_ROOT); " diff --git a/test/automation/d1/test_agent_core_factory.py b/test/automation/d1/test_agent_core_factory.py index 7839f86d0f..6f6a61ac26 100644 --- a/test/automation/d1/test_agent_core_factory.py +++ b/test/automation/d1/test_agent_core_factory.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Formal factory, configuration compilation and observer contracts.""" import asyncio @@ -7,12 +8,14 @@ import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentHistory, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo from nexent.core.agents.context.models import ContextItemInput, ContextItemType -from nexent.core.agents.core_adapters import AgentCoreAdapter, AgentCoreFactory, UnsupportedAgentCoreConfig -from nexent.core.agents.core_adapters.events import JiuwenEventAdapter -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter, _tool_schema -from nexent.core.agents.core_adapters.smolagents import SmolagentsAdapter +from nexent.core.agents.contracts.adapter import AgentCoreAdapter, UnsupportedAgentCoreConfig +from nexent.core.agents.factory import AgentCoreFactory +from nexent.core.agents.cores.jiuwen.events import JiuwenEventAdapter +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter, _tool_schema +from nexent.core.agents.cores.smolagents.adapter import SmolagentsAdapter from nexent.core.utils.observer import MessageObserver @@ -96,7 +99,7 @@ def test_AGENT_CORE_D1_002_native_config_and_fail_closed(monkeypatch, tmp_path): with pytest.raises(UnsupportedAgentCoreConfig, match="prompt_cache"): JiuwenAdapter(info).validate() - from nexent.core.agents.core_adapters.skills import NexentSkillRail + from nexent.core.agents.cores.jiuwen.skills import NexentSkillRail root = tmp_path / "library" allowed = root / "allowed" / "SKILL.md" diff --git a/test/automation/d3/test_agent_core_factory_native.py b/test/automation/d3/test_agent_core_factory_native.py index 8e2ff6ebc5..edc7f6b385 100644 --- a/test/automation/d3/test_agent_core_factory_native.py +++ b/test/automation/d3/test_agent_core_factory_native.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """AGENT-CORE-D3-001: Native Jiuwen execution through the Nexent managed worker.""" import asyncio @@ -12,10 +13,11 @@ import httpx import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentHistory, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo from nexent.core.agents.context.models import ContextItemInput, ContextItemType -from nexent.core.agents.context_input import ContextInput -from nexent.core.agents.run_agent import agent_run +from nexent.core.agents.contracts.context import ContextInput +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver @@ -63,8 +65,8 @@ def test_AGENT_CORE_D3_001_native_worker( ): from openjiuwen.core.runner import Runner - from nexent.core.agents import nexent_agent - from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.agents.cores.smolagents import agent as nexent_agent + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter calls = [] diff --git a/test/automation/d5/test_agent_core_factory_reliability.py b/test/automation/d5/test_agent_core_factory_reliability.py index ef61f76e57..3c951bc190 100644 --- a/test/automation/d5/test_agent_core_factory_reliability.py +++ b/test/automation/d5/test_agent_core_factory_reliability.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """AGENT-CORE-D5-001: Cancellation, MCP deadlines and native resource isolation.""" import asyncio @@ -8,9 +9,10 @@ import httpx import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig, ToolConfig -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.run_agent import agent_run +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import RunCancellationScope from nexent.core.utils.observer import MessageObserver @@ -46,7 +48,7 @@ def test_AGENT_CORE_D5_001_cancellation_deadline_and_isolation(native_manager, n from openjiuwen.core.runner import Runner from nexent.consts.mcp_errors import MCPToolTimeoutError - from nexent.core.agents.core_adapters.mcp import JiuwenMCPCollection + from nexent.core.agents.cores.jiuwen.mcp import JiuwenMCPCollection info = make_run(native_manager, native_provider) diff --git a/test/backend/agents/test_child_model_binding.py b/test/backend/agents/test_child_model_binding.py index b548c2f062..54e08223d4 100644 --- a/test/backend/agents/test_child_model_binding.py +++ b/test/backend/agents/test_child_model_binding.py @@ -4,7 +4,7 @@ import pytest import management.services.agent.preparation.run as service -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig def config(name, core="jiuwen", children=None): diff --git a/test/backend/agents/test_context_budget_v2_pipeline.py b/test/backend/agents/test_context_budget_v2_pipeline.py index 50a2a01e35..4558bbaf17 100644 --- a/test/backend/agents/test_context_budget_v2_pipeline.py +++ b/test/backend/agents/test_context_budget_v2_pipeline.py @@ -1,7 +1,9 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from threading import Event import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.models.capacity_budget import ( CapacityReservePolicy, ContextBudgetCalculator, diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 02cf2603a9..d1efc6bc8e 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -214,8 +214,8 @@ def model_validate(cls, value): return types.SimpleNamespace(enabled=bool((value or {}).get("enabled", False))) sys.modules['nexent.core.utils.observer'] = MagicMock(MessageObserver=mock_message_observer) -sys.modules['nexent.core.agents.agent_model'] = _create_stub_module( - "nexent.core.agents.agent_model", +sys.modules['nexent.core.agents.contracts.config'] = _create_stub_module( + "nexent.core.agents.contracts.config", AgentHistory=AgentHistory, ModelConfig=MagicMock(), AgentConfig=MagicMock(), @@ -225,8 +225,11 @@ def model_validate(cls, value): AgentVerificationConfig=MockAgentVerificationConfig, MessageObserver=MagicMock(), ) -sys.modules['nexent.core.agents.agent_context'] = _create_stub_module( - "nexent.core.agents.agent_context", +sys.modules['nexent.core.agents.contracts.run'] = _create_stub_module( + "nexent.core.agents.contracts.run", AgentHistory=AgentHistory, AgentRunInfo=MagicMock(), +) +sys.modules['nexent.core.agents.context'] = _create_stub_module( + "nexent.core.agents.context", ContextManager=MagicMock(), ContextManagerConfig=MagicMock(), ) @@ -267,8 +270,8 @@ def _load_stub_prompt_bundle(*, is_manager, language, **kwargs): _prompt_loader_stub.load_agent_prompt_bundle = _load_stub_prompt_bundle sys.modules[_prompt_loader_stub.__name__] = _prompt_loader_stub -sys.modules['nexent.core.agents.summary_config'] = _create_stub_module( - "nexent.core.agents.summary_config", +sys.modules['nexent.core.agents.context.config'] = _create_stub_module( + "nexent.core.agents.context.config", ContextManagerConfig=MagicMock(), ) sys.modules['nexent.core.models.prompt_cache'] = _create_stub_module( @@ -389,13 +392,13 @@ def from_dict(cls, data): sandbox_module = _create_stub_module( - "nexent.core.agents.sandbox", + "nexent.core.agents.sandbox.config", SandboxConfig=MockSandboxConfig, ) nexent_agents_module.sandbox = sandbox_module nexent_agent_module = _create_stub_module( - "nexent.core.agents.nexent_agent", + "nexent.core.agents.cores.smolagents.agent", get_local_python_authorized_imports=MagicMock( return_value=["sdk_default_import"] ), @@ -496,11 +499,11 @@ class MockUncertaintyReserveBasisUnknown(Exception): mock_tool_config = MagicMock() mock_agent_run_info = MagicMock() -sys.modules['nexent.core.agents.agent_model'].AgentConfig = mock_agent_config -sys.modules['nexent.core.agents.agent_model'].ModelConfig = mock_model_config -sys.modules['nexent.core.agents.agent_model'].ToolConfig = mock_tool_config -sys.modules['nexent.core.agents.agent_model'].AgentRunInfo = mock_agent_run_info -sys.modules['nexent.core.agents.agent_model'].AgentVerificationConfig = MockAgentVerificationConfig +sys.modules['nexent.core.agents.contracts.config'].AgentConfig = mock_agent_config +sys.modules['nexent.core.agents.contracts.config'].ModelConfig = mock_model_config +sys.modules['nexent.core.agents.contracts.config'].ToolConfig = mock_tool_config +sys.modules['nexent.core.agents.contracts.run'].AgentRunInfo = mock_agent_run_info +sys.modules['nexent.core.agents.contracts.config'].AgentVerificationConfig = MockAgentVerificationConfig sys.modules['nexent.core.utils.observer'].MessageObserver = mock_message_observer # Stub parallel_executor so that create_agent_info can import ParallelExecutorTool diff --git a/test/backend/agents/test_jiuwen_current_task.py b/test/backend/agents/test_jiuwen_current_task.py index 853447c8c5..2ac9089425 100644 --- a/test/backend/agents/test_jiuwen_current_task.py +++ b/test/backend/agents/test_jiuwen_current_task.py @@ -8,7 +8,7 @@ import management.services.agent.preparation.run as service from contracts.conversation import HistoryItem -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig @pytest.mark.parametrize("core,current_upload", [("jiuwen", False), ("jiuwen", True), ("smolagents", False)]) diff --git a/test/backend/agents/test_nl2agent_output_policy.py b/test/backend/agents/test_nl2agent_output_policy.py index 5201e2fbb4..53a5d677a7 100644 --- a/test/backend/agents/test_nl2agent_output_policy.py +++ b/test/backend/agents/test_nl2agent_output_policy.py @@ -3,7 +3,7 @@ import pytest -from nexent.core.agents.agent_model import AgentConfig +from nexent.core.agents.contracts.config import AgentConfig from management.services.agent.creation import create_nl2agent_agent_config diff --git a/test/backend/agents/test_sandbox_config.py b/test/backend/agents/test_sandbox_config.py index 4b27f6f83a..4bf06d1cea 100644 --- a/test/backend/agents/test_sandbox_config.py +++ b/test/backend/agents/test_sandbox_config.py @@ -4,7 +4,7 @@ import pytest from runtime.adapters.nexent.sandbox import resolve_sandbox_config -from nexent.core.agents.sandbox import SandboxLevel, SandboxScope +from nexent.core.agents.sandbox.config import SandboxLevel, SandboxScope def resolve(db_policy, env_policy): diff --git a/test/backend/agents/test_workbench_generation_overlay.py b/test/backend/agents/test_workbench_generation_overlay.py index 2ee4010aa4..34e57002f8 100644 --- a/test/backend/agents/test_workbench_generation_overlay.py +++ b/test/backend/agents/test_workbench_generation_overlay.py @@ -8,7 +8,7 @@ from management.services.agent.preparation.inputs import _materialize_runtime_skill_snapshot from management.services.agent.preparation.run import apply_root_generation_overlay from contracts.agent import WorkbenchGenerationConfig -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig EFFORT_CAPABILITY = {"status": "supported", "control": "effort", "levels": ["none", "minimal", "low", "medium", "high", "xhigh", "max"]} diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index 4d9b091263..7b99e165c4 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -122,7 +122,7 @@ def decorator(func): sys.modules['nexent'] = types.ModuleType('nexent') sys.modules['nexent.core'] = types.ModuleType('nexent.core') sys.modules['nexent.core.agents'] = types.ModuleType('nexent.core.agents') -sys.modules['nexent.core.agents.agent_model'] = agent_model_stub +sys.modules['nexent.core.agents.contracts.config'] = agent_model_stub sys.modules['nexent.monitor'] = monitoring_stub sys.modules['nexent.monitor.monitoring'] = monitoring_stub sys.modules['management.db.client'] = MagicMock() @@ -134,7 +134,7 @@ def decorator(func): sys.modules["management.services.agent.preparation.run"] = MagicMock() sys.modules["management.services.agent.preparation.tools"] = MagicMock() -sys.modules['nexent.core.agents.run_agent'] = MagicMock() +sys.modules['nexent.core.agents.orchestration.entry'] = MagicMock() sys.modules['gateway.authentication.auth'] = MagicMock() sys.modules['gateway.authentication.auth'].get_current_user_id.return_value = ('test_user', 'test_tenant') sys.modules['management.services.configuration.tenant.settings'] = MagicMock() diff --git a/test/backend/services/test_agent_evaluation_service.py b/test/backend/services/test_agent_evaluation_service.py index bad9edd274..49fbf3f447 100644 --- a/test/backend/services/test_agent_evaluation_service.py +++ b/test/backend/services/test_agent_evaluation_service.py @@ -172,13 +172,13 @@ def model_dump(self, **kwargs): _agent_model_mock.AgentVerificationConfig = _MockAgentVerificationConfig _agent_model_mock.ToolConfig = _MockToolConfig -sys.modules["nexent.core.agents.agent_model"] = _agent_model_mock -sys.modules["nexent.core.agents.agent_context"] = MagicMock() -sys.modules["nexent.core.agents.run_agent"] = MagicMock() +sys.modules["nexent.core.agents.contracts.config"] = _agent_model_mock +sys.modules["nexent.core.agents.context"] = MagicMock() +sys.modules["nexent.core.agents.orchestration.entry"] = MagicMock() -_nexent_core_agents_sandbox_module = types.ModuleType("nexent.core.agents.sandbox") +_nexent_core_agents_sandbox_module = types.ModuleType("nexent.core.agents.sandbox.execution") _nexent_core_agents_sandbox_module._scan_shell_calls = MagicMock(return_value=[]) -sys.modules["nexent.core.agents.sandbox"] = _nexent_core_agents_sandbox_module +sys.modules["nexent.core.agents.sandbox.execution"] = _nexent_core_agents_sandbox_module _nexent_core_agents.sandbox = _nexent_core_agents_sandbox_module sys.modules["nexent.core.utils.observer"] = MagicMock() diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py index dbef718c4c..204158d1da 100644 --- a/test/backend/services/test_agent_service.py +++ b/test/backend/services/test_agent_service.py @@ -177,9 +177,9 @@ async def _run_managed(_lane, _spec, fn, *args, **kwargs): sys.modules["nexent.core.agents"] = MagicMock() sys.modules["nexent.core.agents"].__path__ = [str(Path(__file__).resolve().parents[3] / "sdk/nexent/core/agents")] sys.modules["nexent.core.agents"].__spec__ = importlib.machinery.ModuleSpec("nexent.core.agents", loader=None, is_package=True) -sys.modules["nexent.core.agents.agent_model"] = nexent_agent_model_mock -sys.modules["nexent.core.agents.run_agent"] = MagicMock() -context_input_mock = types.ModuleType("nexent.core.agents.context_input") +sys.modules["nexent.core.agents.contracts.config"] = nexent_agent_model_mock +sys.modules["nexent.core.agents.orchestration.entry"] = MagicMock() +context_input_mock = types.ModuleType("nexent.core.agents.contracts.context") class MockContextInput: @@ -188,9 +188,10 @@ def __init__(self, items=()): context_input_mock.ContextInput = MockContextInput -sys.modules["nexent.core.agents.context_input"] = context_input_mock +sys.modules["nexent.core.agents.contracts.context"] = context_input_mock context_items_mock = types.ModuleType("nexent.core.agents.context") context_items_mock.ContextItemInput = MagicMock() +context_input_mock.ContextItemInput = context_items_mock.ContextItemInput sys.modules["nexent.core.agents.context"] = context_items_mock # Mock other nexent submodules @@ -567,7 +568,7 @@ def __init__(self, *args, **kwargs): # STEP 2: Now import after all mocks are in place # ============================================================================= -from nexent.core.agents.agent_model import ToolConfig +from nexent.core.agents.contracts.config import ToolConfig from contracts.agent import AgentNameBatchCheckItem, AgentNameBatchCheckRequest, AgentNameBatchRegenerateItem, AgentNameBatchRegenerateRequest # Set up mock for nexent.monitor @@ -5694,7 +5695,7 @@ async def fake_agent_run(*_, **__): yield json.dumps({"type": "model_output_code", "content": "pass"}) yield json.dumps({"type": "final_answer", "content": "All done."}) - monkeypatch.setitem(sys.modules, "nexent.core.agents.run_agent", MagicMock()) + monkeypatch.setitem(sys.modules, "nexent.core.agents.orchestration.entry", MagicMock()) monkeypatch.setattr( "management.services.agent.execution.agent_run", fake_agent_run, raising=False ) @@ -7652,7 +7653,7 @@ async def test_import_agent_includes_model_names( mock_create_agent.return_value = {"agent_id": 999} # Create import data with model_name and business_logic_model_name - from nexent.core.agents.agent_model import ToolConfig as NexentToolConfig + from nexent.core.agents.contracts.config import ToolConfig as NexentToolConfig tool_config = NexentToolConfig( class_name="TestTool", @@ -11022,6 +11023,8 @@ async def test_import_agent_impl_already_imported(): from management.services.agent.service import import_agent_impl from contracts.agent import ExportAndImportDataFormat, ExportAndImportAgentInfo mock_agent_info = MagicMock(spec=ExportAndImportAgentInfo) + mock_agent_info.agent_core = "smolagents" + mock_agent_info.enable_context_manager = True mock_agent_info.agent_id = 1 mock_agent_info.name = 'agent_1' mock_agent_info.display_name = 'Agent 1' @@ -11382,6 +11385,8 @@ async def test_import_agent_by_agent_id_publish_version_error( from contracts.agent import ExportAndImportAgentInfo mock_agent_info = MagicMock(spec=ExportAndImportAgentInfo) + mock_agent_info.agent_core = "smolagents" + mock_agent_info.enable_context_manager = True mock_agent_info.agent_id = 1 mock_agent_info.name = "valid_name" mock_agent_info.display_name = "Valid Name" diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 2f015a91df..89ee4e6b24 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -37,19 +37,19 @@ def generate(self, messages): # Exercise the real Jinja renderer used by SDK prompt assembly. import jinja2 -# Stub nexent.core.agents.agent_model to satisfy imports in contracts.agent and agent_run_manager -agent_model_mod = types.ModuleType("nexent.core.agents.agent_model") +# Stub nexent.core.agents.contracts.config to satisfy imports in contracts.agent and agent_run_manager +agent_model_mod = types.ModuleType("nexent.core.agents.contracts.config") agent_model_mod.ToolConfig = object agent_model_mod.AgentRunInfo = object sys.modules["nexent.core.agents"] = types.ModuleType("nexent.core.agents") sys.modules["nexent.core.agents"].__path__ = [str(Path(__file__).resolve().parents[3] / "sdk/nexent/core/agents")] -sys.modules["nexent.core.agents.agent_model"] = agent_model_mod +sys.modules["nexent.core.agents.contracts.config"] = agent_model_mod -# Stub nexent.core.agents.agent_context for agent_run_manager import -agent_context_mod = types.ModuleType("nexent.core.agents.agent_context") +# Stub nexent.core.agents.context for agent_run_manager import +agent_context_mod = types.ModuleType("nexent.core.agents.context") agent_context_mod.ContextManager = object agent_context_mod.ContextManagerConfig = object -sys.modules["nexent.core.agents.agent_context"] = agent_context_mod +sys.modules["nexent.core.agents.context"] = agent_context_mod # Stub management.services.agent.lifecycle to avoid importing the real module agent_run_manager_mod = types.ModuleType("management.services.agent.lifecycle") diff --git a/test/backend/services/test_evaluation_pure_logic.py b/test/backend/services/test_evaluation_pure_logic.py index 561a9deb52..b28cb89544 100644 --- a/test/backend/services/test_evaluation_pure_logic.py +++ b/test/backend/services/test_evaluation_pure_logic.py @@ -72,7 +72,7 @@ def _scan_shell_calls(code: str) -> list[str]: """Static AST scan – returns a list of human-readable violation descriptions. - Mirrors the behaviour of ``nexent.core.agents.sandbox._scan_shell_calls`` + Mirrors the behaviour of ``nexent.core.agents.sandbox.execution._scan_shell_calls`` closely enough that the UT decision paths match production semantics. """ try: @@ -262,14 +262,14 @@ def __init__(self, **kwargs): sys.modules["nexent.core.concurrency"] = concurrency_mock _nexent_core.concurrency = concurrency_mock - run_agent_mock = types.ModuleType("nexent.core.agents.run_agent") + run_agent_mock = types.ModuleType("nexent.core.agents.orchestration.entry") run_agent_mock.agent_run = lambda *a, **kw: None - sys.modules["nexent.core.agents.run_agent"] = run_agent_mock + sys.modules["nexent.core.agents.orchestration.entry"] = run_agent_mock _nexent_core_agents.run_agent = run_agent_mock - sandbox_mock = types.ModuleType("nexent.core.agents.sandbox") + sandbox_mock = types.ModuleType("nexent.core.agents.sandbox.execution") sandbox_mock._scan_shell_calls = staticmethod(_scan_shell_calls) - sys.modules["nexent.core.agents.sandbox"] = sandbox_mock + sys.modules["nexent.core.agents.sandbox.execution"] = sandbox_mock _nexent_core_agents.sandbox = sandbox_mock # ---- adapters ---------------------------------------------------------- diff --git a/test/backend/services/test_evaluation_set_service.py b/test/backend/services/test_evaluation_set_service.py index 75de0ce740..47f2150df9 100644 --- a/test/backend/services/test_evaluation_set_service.py +++ b/test/backend/services/test_evaluation_set_service.py @@ -94,9 +94,9 @@ def model_dump(self, **kwargs): _agent_model_mock.AgentVerificationConfig = _MockAgentVerificationConfig _agent_model_mock.ToolConfig = _MockToolConfig -sys.modules["nexent.core.agents.agent_model"] = _agent_model_mock -sys.modules["nexent.core.agents.agent_context"] = MagicMock() -sys.modules["nexent.core.agents.run_agent"] = MagicMock() +sys.modules["nexent.core.agents.contracts.config"] = _agent_model_mock +sys.modules["nexent.core.agents.context"] = MagicMock() +sys.modules["nexent.core.agents.orchestration.entry"] = MagicMock() sys.modules["nexent.core.utils.observer"] = MagicMock() sys.modules["nexent.core.utils.common"] = MagicMock() sys.modules["nexent.memory.memory_service"] = MagicMock() diff --git a/test/backend/services/test_knowledge_scope_service.py b/test/backend/services/test_knowledge_scope_service.py index 787555528e..c1403ce245 100644 --- a/test/backend/services/test_knowledge_scope_service.py +++ b/test/backend/services/test_knowledge_scope_service.py @@ -27,7 +27,7 @@ def test_executable_roots_use_runtime_refs_without_agent_repository_reads(mocker): """UT-BE-WB-016/UT-BE-WB-023: same-name system nodes retain separate scopes.""" - from nexent.core.agents.agent_model import AgentConfig, ToolConfig + from nexent.core.agents.contracts.config import AgentConfig, ToolConfig def node(ref, index): return AgentConfig( @@ -66,7 +66,7 @@ def node(ref, index): @pytest.mark.parametrize("child_ref", [None, "system:root"]) def test_executable_knowledge_rejects_ambiguous_runtime_identity(child_ref, mocker): """UT-BE-WB-023: invalid executable identity fails before resource access.""" - from nexent.core.agents.agent_model import AgentConfig + from nexent.core.agents.contracts.config import AgentConfig root = AgentConfig(runtime_ref="system:root", name="root", description="test", model_name="model", tools=[]) root.worker_agents = [root.model_copy(update={"runtime_ref": child_ref})] diff --git a/test/backend/services/test_model_runtime_provisioning_service.py b/test/backend/services/test_model_runtime_provisioning_service.py index f8ddfac0f9..ac9195db36 100644 --- a/test/backend/services/test_model_runtime_provisioning_service.py +++ b/test/backend/services/test_model_runtime_provisioning_service.py @@ -8,7 +8,7 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig from management.services.model.runtime_options import compile_model_runtime_profile from management.services.model.runtime_provisioning import ( build_runtime_outlet_snapshot, @@ -122,7 +122,7 @@ def test_compile_rejects_protected_extra_fields_and_missing_profile(saved): def test_effective_request_budget_takes_precedence_over_saved_agent_output(saved): - from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter from nexent.core.concurrency import RunCancellationScope profile = restore_runtime_model_snapshot(saved, "tenant") diff --git a/test/backend/services/test_nl2agent_name_validation.py b/test/backend/services/test_nl2agent_name_validation.py index dd6b8d71c7..bf33376706 100644 --- a/test/backend/services/test_nl2agent_name_validation.py +++ b/test/backend/services/test_nl2agent_name_validation.py @@ -8,7 +8,7 @@ from types import SimpleNamespace import pytest -from nexent.core.agents.core_agent import CoreAgent +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent from pydantic import ValidationError from management.services.agent.creation import build_nl2agent_system_prompt diff --git a/test/backend/services/test_nl2skill_continuation.py b/test/backend/services/test_nl2skill_continuation.py index d7ec422909..83e4966243 100644 --- a/test/backend/services/test_nl2skill_continuation.py +++ b/test/backend/services/test_nl2skill_continuation.py @@ -8,7 +8,7 @@ import services.nl2skill_service as service from contracts.agent import NL2SkillRunRequest from nexent.core.agents.context import ContextManager, ContextManagerConfig -from nexent.core.agents.run_agent import _get_authorized_history +from nexent.core.agents.orchestration.entry import _get_authorized_history @pytest.mark.asyncio diff --git a/test/backend/services/test_run_model_controls.py b/test/backend/services/test_run_model_controls.py index 2911d7c043..81ff160835 100644 --- a/test/backend/services/test_run_model_controls.py +++ b/test/backend/services/test_run_model_controls.py @@ -5,7 +5,7 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import AgentConfig, ModelConfig +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig from pydantic import ValidationError from contracts.errors.exceptions import ForbiddenError, RunModelControlsError diff --git a/test/backend/services/test_sampling_model_controls.py b/test/backend/services/test_sampling_model_controls.py index 9a82566b45..92899eebff 100644 --- a/test/backend/services/test_sampling_model_controls.py +++ b/test/backend/services/test_sampling_model_controls.py @@ -4,7 +4,7 @@ import pytest from pydantic import ValidationError -from nexent.core.agents.agent_model import ModelConfig +from nexent.core.agents.contracts.config import ModelConfig from contracts.agent import WorkbenchGenerationConfig, RuntimeGenerationOverrides, NL2SkillRunRequest from contracts.errors.exceptions import RunModelControlsError diff --git a/test/backend/services/test_skill_service.py b/test/backend/services/test_skill_service.py index 6415f3d0ee..8793b41c23 100644 --- a/test/backend/services/test_skill_service.py +++ b/test/backend/services/test_skill_service.py @@ -37,7 +37,7 @@ nexent_mock = types.ModuleType('nexent') nexent_core_mock = types.ModuleType('nexent.core') nexent_core_agents_mock = types.ModuleType('nexent.core.agents') -nexent_core_agents_agent_model_mock = types.ModuleType('nexent.core.agents.agent_model') +nexent_core_agents_agent_model_mock = types.ModuleType('nexent.core.agents.contracts.config') nexent_skills_mock = types.ModuleType('nexent.skills') nexent_skills_mock.__path__ = [os.path.join(os.path.dirname(__file__), '../../../sdk/nexent/skills')] # Required for submodule lookups nexent_skills_skill_loader_mock = types.ModuleType('nexent.skills.skill_loader') @@ -70,7 +70,7 @@ class MockToolConfig: sys.modules['nexent'] = nexent_mock sys.modules['nexent.core'] = nexent_core_mock sys.modules['nexent.core.agents'] = nexent_core_agents_mock -sys.modules['nexent.core.agents.agent_model'] = nexent_core_agents_agent_model_mock +sys.modules['nexent.core.agents.contracts.config'] = nexent_core_agents_agent_model_mock sys.modules['nexent.skills'] = nexent_skills_mock sys.modules['nexent.skills.skill_loader'] = nexent_skills_skill_loader_mock sys.modules['nexent.skills.skill_manager'] = nexent_skills_skill_manager_mock @@ -408,10 +408,10 @@ def mock_update_skill_by_id(skill_id, skill_data, tenant_id=None, updated_by=Non sys.modules['management.db.identity.user_tenant'] = database_user_tenant_db_mock setattr(database_mock, 'skill_db', database_skill_db_mock) -# Mock nexent.core.agents.run_agent for create_skill_from_request -nexent_core_agents_run_agent_mock = types.ModuleType('nexent.core.agents.run_agent') +# Mock nexent.core.agents.orchestration.entry for create_skill_from_request +nexent_core_agents_run_agent_mock = types.ModuleType('nexent.core.agents.orchestration.entry') nexent_core_agents_run_agent_mock.agent_run_thread = MagicMock() -sys.modules['nexent.core.agents.run_agent'] = nexent_core_agents_run_agent_mock +sys.modules['nexent.core.agents.orchestration.entry'] = nexent_core_agents_run_agent_mock # Mock agents.skill_creation_agent module agents_mock = types.ModuleType('agents') diff --git a/test/backend/services/test_tool_configuration_service.py b/test/backend/services/test_tool_configuration_service.py index 2e95f07971..01cf78d2dd 100644 --- a/test/backend/services/test_tool_configuration_service.py +++ b/test/backend/services/test_tool_configuration_service.py @@ -219,8 +219,8 @@ def _create_package_mock(name): nexent_mock.memory = memory_pkg if real_memory_service is not None: sys.modules["nexent.memory.memory_service"] = real_memory_service -sys.modules['nexent.core.agents.agent_model'] = MagicMock() -sys.modules['nexent.core.agents.run_agent'] = MagicMock() +sys.modules['nexent.core.agents.contracts.config'] = MagicMock() +sys.modules['nexent.core.agents.orchestration.entry'] = MagicMock() sys.modules['nexent.core.models'] = _create_package_mock('nexent.core.models') # Mock nexent.multi_modal module diff --git a/test/backend/services/test_workbench_service.py b/test/backend/services/test_workbench_service.py index ba81f72ecb..3899e67381 100644 --- a/test/backend/services/test_workbench_service.py +++ b/test/backend/services/test_workbench_service.py @@ -9,8 +9,8 @@ from contracts.errors.exceptions import ValidationError, WorkbenchConfigVersionConflict from contracts.agent import WorkbenchSessionConfig from management.services.agent import workbench as workbench_service -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.agent_model import ToolConfig +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.contracts.config import ToolConfig from threading import local from types import SimpleNamespace @@ -295,7 +295,7 @@ def resolve(skill_id, knowledge_id): def test_system_root_knowledge_mount_uses_the_executable_contract(mocker): """UT-BE-WB-023: external callers resolve knowledge without persisted Agent IDs.""" - from nexent.core.agents.agent_model import ToolConfig + from nexent.core.agents.contracts.config import ToolConfig identity = workbench_service.RuntimeAgentIdentity("system:root", None, None, "system_root", "Root", "SYSTEM") config = AgentConfig(name="root", description="test", model_name="model", tools=[ diff --git a/test/backend/utils/test_time_context_localization.py b/test/backend/utils/test_time_context_localization.py index 0fe8d1f488..a7e0c4a561 100644 --- a/test/backend/utils/test_time_context_localization.py +++ b/test/backend/utils/test_time_context_localization.py @@ -44,7 +44,7 @@ def time_context(monkeypatch): for name, values in { "contracts.agent": {"AgentRunContext": object}, "nexent.core.agents.context": {"ContextItemInput": object}, - "nexent.core.agents.context_input": {"ContextInput": object}, + "nexent.core.agents.contracts.context": {"ContextInput": object}, "nexent.monitor": {"AgentRunMetadata": object}, "management.services.configuration.user.memory": {"build_memory_context": lambda *args, **kwargs: None}, "infrastructure.observability.bootstrap": {"monitoring_manager": object()}, diff --git a/test/sdk/core/agents/observability/test_agent_display_name.py b/test/sdk/core/agents/observability/test_agent_display_name.py index ff063b8ead..94a06493fb 100644 --- a/test/sdk/core/agents/observability/test_agent_display_name.py +++ b/test/sdk/core/agents/observability/test_agent_display_name.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """UT-SDK-TRACE-019/020: display names never replace executable identifiers.""" from threading import Event @@ -5,9 +6,9 @@ import pytest -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.core_agent import CoreAgent -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.utils.observer import MessageObserver from nexent.monitor import AgentRunMetadata diff --git a/test/sdk/core/agents/observability/test_bridge_spans.py b/test/sdk/core/agents/observability/test_bridge_spans.py index 4fc8b376c4..f8b3d13c71 100644 --- a/test/sdk/core/agents/observability/test_bridge_spans.py +++ b/test/sdk/core/agents/observability/test_bridge_spans.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """UT-SDK-TRACE-003/004/006/007 and local HTTP bridge integration.""" import logging @@ -6,7 +7,7 @@ from types import SimpleNamespace import pytest -from nexent.core.agents import sandbox +from nexent.core.agents.sandbox import execution as sandbox from nexent.core.concurrency import ManagedTaskSpec, get_current_thread_manager from nexent.core.tools.parallel_executor import _parallel_executor from nexent.monitor import ( diff --git a/test/sdk/core/agents/observability/test_managed_call_spans.py b/test/sdk/core/agents/observability/test_managed_call_spans.py index 6014be3b6b..4cafd2e3b2 100644 --- a/test/sdk/core/agents/observability/test_managed_call_spans.py +++ b/test/sdk/core/agents/observability/test_managed_call_spans.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """UT-SDK-TRACE-006: preserve existing thread and asyncio call ancestry.""" import asyncio @@ -6,8 +7,8 @@ import mcp.types import pytest -from nexent.core.agents.managed_mcp import ManagedMCPToolCollection -from nexent.core.agents.nexent_agent import _wrap_tool_with_monitoring +from nexent.core.agents.resources.mcp import ManagedMCPToolCollection +from nexent.core.agents.cores.smolagents.agent import _wrap_tool_with_monitoring from nexent.core.concurrency import ( ManagedTaskSpec, RunCancellationScope, diff --git a/test/sdk/core/agents/observability/test_run_span_context.py b/test/sdk/core/agents/observability/test_run_span_context.py index 6cbb1d45c2..ae8f2349b1 100644 --- a/test/sdk/core/agents/observability/test_run_span_context.py +++ b/test/sdk/core/agents/observability/test_run_span_context.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """UT-SDK-TRACE-001/002: admission and complete worker span lifetime.""" import importlib @@ -21,7 +22,8 @@ from .conftest import assert_child, by_name -run_module = importlib.import_module("nexent.core.agents.run_agent") +run_module = importlib.import_module("nexent.core.agents.orchestration.entry") +runner_module = importlib.import_module("nexent.core.agents.cores.smolagents.runner") def test_ut_sdk_trace_001_deferred_binding_restores_request_and_worker_manager(spans, threads, monkeypatch): @@ -113,10 +115,10 @@ def mcp(**kwargs): finally: phase("mcp-close") - monkeypatch.setattr(run_module, "NexentAgent", create) - monkeypatch.setattr(run_module, "ManagedMCPToolCollection", mcp) + monkeypatch.setattr(runner_module, "NexentAgent", create) + monkeypatch.setattr(runner_module, "ManagedMCPToolCollection", mcp) monkeypatch.setattr(run_module, "cleanup_run_workspace", lambda *a: phase("cleanup")) - monkeypatch.setattr(run_module, "_log_memory_value_assessment", lambda *a: None) + monkeypatch.setattr(runner_module, "_log_memory_value_assessment", lambda *a: None) with spans.tracer.start_as_current_span("request"), agent_monitoring_context( AgentRunMetadata(agent_id=1, tenant_id="tenant", conversation_id=42) ): diff --git a/test/sdk/core/agents/observability/test_sandbox_warmup.py b/test/sdk/core/agents/observability/test_sandbox_warmup.py index de104f45f8..22410cf750 100644 --- a/test/sdk/core/agents/observability/test_sandbox_warmup.py +++ b/test/sdk/core/agents/observability/test_sandbox_warmup.py @@ -9,9 +9,9 @@ from opentelemetry import trace from opentelemetry.trace import StatusCode -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel, SandboxScope +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel, SandboxScope from nexent.core.concurrency import ManagedTaskSpec from nexent.core.utils.observer import MessageObserver from nexent.monitor import AgentRunMetadata, get_agent_monitoring_context @@ -42,9 +42,9 @@ def __call__(self, task): tool_fastpath_enabled=False, ) mocker.patch.object(NexentAgent, "create_model", side_effect=lambda *_args: mocker.Mock()) - mocker.patch("nexent.core.agents.nexent_agent.CoreAgent", new=WarmupAgent) - mocker.patch("nexent.core.agents.sandbox.SandboxSkillScriptRunner") - mocker.patch("nexent.core.agents.sandbox.release_python_executor") + mocker.patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", new=WarmupAgent) + mocker.patch("nexent.core.agents.sandbox.execution.SandboxSkillScriptRunner") + mocker.patch("nexent.core.agents.sandbox.execution.release_python_executor") return runtime @@ -63,7 +63,7 @@ def execute(code): intervals.append((start, time.time_ns())) executors = [mocker.Mock(_nexent_backend="docker", side_effect=execute) for _ in range(2)] - build = mocker.patch("nexent.core.agents.sandbox.build_python_executor", side_effect=executors) + build = mocker.patch("nexent.core.agents.sandbox.execution.build_python_executor", side_effect=executors) child = config("child", "子智能体") child._sub_agent_id = 23 metadata = AgentRunMetadata( @@ -118,7 +118,7 @@ def execute(code): def test_sdk_ut_013_02_backend_and_fallback(spans, mocker, factory, level, backend): factory.sandbox_config.level = level executor = mocker.Mock(_nexent_backend=backend) - mocker.patch("nexent.core.agents.sandbox.build_python_executor", return_value=executor) + mocker.patch("nexent.core.agents.sandbox.execution.build_python_executor", return_value=executor) with spans.manager.start_agent_run(): factory.create_single_agent(config()) span, = warmups(spans) @@ -131,7 +131,7 @@ def test_sdk_ut_013_02_backend_and_fallback(spans, mocker, factory, level, backe def test_sdk_ut_013_03_failure_is_recorded_without_changing_continue_behavior(spans, mocker, factory, caplog): executor = mocker.Mock(_nexent_backend="docker", side_effect=RuntimeError("warm-up unavailable")) - mocker.patch("nexent.core.agents.sandbox.build_python_executor", return_value=executor) + mocker.patch("nexent.core.agents.sandbox.execution.build_python_executor", return_value=executor) with spans.manager.start_agent_run(): parent = trace.get_current_span() result = factory.create_single_agent(config()) @@ -160,7 +160,7 @@ def test_sdk_ut_013_04_no_spurious_warmups(spans, mocker, factory, mode): else: spans.manager._config.enable_telemetry = False executor = mocker.Mock(_nexent_backend="docker") - build = mocker.patch("nexent.core.agents.sandbox.build_python_executor", return_value=executor) + build = mocker.patch("nexent.core.agents.sandbox.execution.build_python_executor", return_value=executor) factory.create_single_agent(config()) assert not warmups(spans) if mode == "disabled": @@ -179,7 +179,7 @@ def execute(_code): pass mocker.patch( - "nexent.core.agents.sandbox.build_python_executor", + "nexent.core.agents.sandbox.execution.build_python_executor", side_effect=lambda **kwargs: mocker.Mock(_nexent_backend="docker", side_effect=execute), ) diff --git a/test/sdk/core/agents/observability/test_subagent_spans.py b/test/sdk/core/agents/observability/test_subagent_spans.py index bef69ef58d..b073fe5ba2 100644 --- a/test/sdk/core/agents/observability/test_subagent_spans.py +++ b/test/sdk/core/agents/observability/test_subagent_spans.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """UT-SDK-TRACE-005/007: real sub-agent hierarchy and context restoration.""" from types import SimpleNamespace import pytest -from nexent.core.agents.a2a_agent_proxy import ExternalA2AAgentWrapper -from nexent.core.agents.subagent_wrapper import SubAgentToolWrapper +from nexent.core.agents.resources.a2a import ExternalA2AAgentWrapper +from nexent.core.agents.resources.subagents import SubAgentToolWrapper from nexent.monitor import ( AgentRunMetadata, agent_monitoring_context, diff --git a/test/sdk/core/agents/observability/test_trace_name.py b/test/sdk/core/agents/observability/test_trace_name.py index 36ec90f8b0..0b84fd969f 100644 --- a/test/sdk/core/agents/observability/test_trace_name.py +++ b/test/sdk/core/agents/observability/test_trace_name.py @@ -7,7 +7,7 @@ import pytest from opentelemetry import trace -from nexent.core.agents.subagent_wrapper import SubAgentToolWrapper +from nexent.core.agents.resources.subagents import SubAgentToolWrapper from nexent.monitor import AgentRunMetadata, get_agent_monitoring_context from .conftest import assert_child, by_name diff --git a/test/sdk/core/agents/test_a2a_active_cancellation.py b/test/sdk/core/agents/test_a2a_active_cancellation.py index 86cfbdbfeb..7ce754dd54 100644 --- a/test/sdk/core/agents/test_a2a_active_cancellation.py +++ b/test/sdk/core/agents/test_a2a_active_cancellation.py @@ -1,7 +1,8 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import asyncio import pytest -from nexent.core.agents.a2a_agent_proxy import A2AAgentInfo, ExternalA2AAgentProxy +from nexent.core.agents.resources.a2a import A2AAgentInfo, ExternalA2AAgentProxy from nexent.core.concurrency import RunCancellationScope @@ -52,7 +53,7 @@ async def test_ut_sdk_tlm_030_run_cancel_cancels_active_a2a_task(monkeypatch): entered = asyncio.Event() client = _BlockingClient(entered) monkeypatch.setattr( - "nexent.core.agents.a2a_agent_proxy.httpx.AsyncClient", + "nexent.core.agents.resources.a2a.httpx.AsyncClient", lambda **kwargs: client, ) scope = RunCancellationScope() @@ -79,7 +80,7 @@ async def test_ut_sdk_tlm_030_total_deadline_closes_a2a_client(monkeypatch, capl entered = asyncio.Event() client = _BlockingClient(entered) monkeypatch.setattr( - "nexent.core.agents.a2a_agent_proxy.httpx.AsyncClient", + "nexent.core.agents.resources.a2a.httpx.AsyncClient", lambda **kwargs: client, ) proxy = ExternalA2AAgentProxy( @@ -108,7 +109,7 @@ async def test_ut_sdk_tlm_030_total_deadline_covers_partial_stream_stall(monkeyp entered = asyncio.Event() client = _BlockingStreamingClient(entered) monkeypatch.setattr( - "nexent.core.agents.a2a_agent_proxy.httpx.AsyncClient", + "nexent.core.agents.resources.a2a.httpx.AsyncClient", lambda **kwargs: client, ) proxy = ExternalA2AAgentProxy( diff --git a/test/sdk/core/agents/test_a2a_agent_proxy.py b/test/sdk/core/agents/test_a2a_agent_proxy.py index 401dc774e2..65567ec4c4 100644 --- a/test/sdk/core/agents/test_a2a_agent_proxy.py +++ b/test/sdk/core/agents/test_a2a_agent_proxy.py @@ -1,7 +1,7 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ -Unit tests for sdk.nexent.core.agents.a2a_agent_proxy module. +Unit tests for nexent.core.agents.resources.a2a module. Tests cover: - A2AAgentInfo dataclass @@ -9,7 +9,7 @@ - A2AAgentProxyTool class - ExternalA2AAgentWrapper class -Uses direct module loading to bypass the sdk.nexent package __init__.py +Uses direct module loading to bypass the nexent package __init__.py which has heavy dependencies (mem0, smolagents, etc.) not needed for testing this specific module. """ @@ -131,7 +131,7 @@ def _make_mock_response_with_aiter_lines(lines): # --------------------------------------------------------------------------- -# Build minimal module mock hierarchy to satisfy sdk.nexent.core imports +# Build minimal module mock hierarchy to satisfy nexent.core imports # --------------------------------------------------------------------------- def _create_mock_modules(): @@ -141,8 +141,8 @@ def _create_mock_modules(): # Mock httpx mock_modules["httpx"] = _mock_httpx - # sdk.nexent.core.utils.observer - mock_observer = ModuleType("sdk.nexent.core.utils.observer") + # nexent.core.utils.observer + mock_observer = ModuleType("nexent.core.utils.observer") class ProcessType: STEP_COUNT = "STEP_COUNT" @@ -171,7 +171,7 @@ def append_message(self, text): setattr(mock_observer, "MessageObserver", MessageObserver) setattr(mock_observer, "ProcessType", ProcessType) - mock_modules["sdk.nexent.core.utils.observer"] = mock_observer + mock_modules["nexent.core.utils.observer"] = mock_observer # smolagents module mock_smolagents = ModuleType("smolagents") @@ -194,13 +194,7 @@ def append_message(self, text): # Register mocks in sys.modules and load target module directly # --------------------------------------------------------------------------- -_original_modules = {} -for name, module in _mock_modules.items(): - if name in sys.modules: - _original_modules[name] = sys.modules[name] - sys.modules[name] = module - - +@patch.dict(sys.modules, _mock_modules) def _load_a2a_agent_proxy_module(): """Load a2a_agent_proxy module directly from source file. @@ -214,14 +208,14 @@ def _load_a2a_agent_proxy_module(): → project_root (e.g. C:/xuyaqi/develop/nexent) """ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) - module_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "a2a_agent_proxy.py") + module_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "resources", "a2a.py") # Set up package hierarchy so the module can find its siblings sys.modules["sdk"] = ModuleType("sdk") - sys.modules["sdk.nexent"] = ModuleType("sdk.nexent") - sys.modules["sdk.nexent.core"] = ModuleType("sdk.nexent.core") - sys.modules["sdk.nexent.core.agents"] = ModuleType("sdk.nexent.core.agents") - concurrency_module = ModuleType("sdk.nexent.core.concurrency") + sys.modules["nexent"] = ModuleType("nexent") + sys.modules["nexent.core"] = ModuleType("nexent.core") + sys.modules["nexent.core.agents"] = ModuleType("nexent.core.agents") + concurrency_module = ModuleType("nexent.core.concurrency") class ManagedTaskSpec: def __init__(self, task_name, owner, close_hook=None): @@ -240,12 +234,12 @@ def run_sync(self, _lane, _spec, fn, *args, **kwargs): concurrency_module.ManagedTaskSpec = ManagedTaskSpec concurrency_module.RunCancellationScope = MagicMock concurrency_module.get_current_thread_manager = lambda: DirectTestManager() - sys.modules["sdk.nexent.core.concurrency"] = concurrency_module + sys.modules["nexent.core.concurrency"] = concurrency_module - spec = importlib.util.spec_from_file_location("sdk.nexent.core.agents.a2a_agent_proxy", module_path) + spec = importlib.util.spec_from_file_location("nexent.core.agents.resources.a2a", module_path) module = importlib.util.module_from_spec(spec) - module.__package__ = "sdk.nexent.core.agents" - sys.modules["sdk.nexent.core.agents.a2a_agent_proxy"] = module + module.__package__ = "nexent.core.agents.resources" + sys.modules["nexent.core.agents.resources.a2a"] = module spec.loader.exec_module(module) return module diff --git a/test/sdk/core/agents/test_agent_invocation_config.py b/test/sdk/core/agents/test_agent_invocation_config.py index a2591e5915..516381a413 100644 --- a/test/sdk/core/agents/test_agent_invocation_config.py +++ b/test/sdk/core/agents/test_agent_invocation_config.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Configuration cloning preserves services but isolates invocation data.""" from dataclasses import dataclass @@ -7,14 +8,10 @@ import pytest -from nexent.core.agents.agent_model import ( - AgentConfig, - ToolConfig, - copy_invocation_config, -) -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel -from nexent.core.agents.tool_fastpath import DeferredToolExecutor +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig, copy_invocation_config +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool from nexent.core.utils.observer import MessageObserver @@ -64,7 +61,7 @@ def test_clone_preserves_cycles_without_sharing_mutable_metadata(): def test_invocation_factory_passes_isolated_config_and_original_client_to_tools(monkeypatch): - from nexent.core.agents import nexent_agent + from nexent.core.agents.cores.smolagents import agent as nexent_agent class FakeCore: def __init__(self, **kwargs): @@ -103,7 +100,8 @@ def create_tool(_owner, config): (False, None), (False, ["ReadSkillConfigTool"]), ]) def test_child_invocations_inherit_fastpath_policy_and_release_distinct_executors(monkeypatch, enabled, allowed): - from nexent.core.agents import nexent_agent, sandbox + from nexent.core.agents.cores.smolagents import agent as nexent_agent + from nexent.core.agents.sandbox import execution as sandbox executors = [] diff --git a/test/sdk/core/agents/test_agent_model.py b/test/sdk/core/agents/test_agent_model.py index 533c86e9a5..14bdd808e9 100644 --- a/test/sdk/core/agents/test_agent_model.py +++ b/test/sdk/core/agents/test_agent_model.py @@ -1,7 +1,7 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ -Unit tests for sdk.nexent.core.agents.agent_model module. +Unit tests for sdk.nexent.core.agents.contracts.config module. This module tests all Pydantic models and related constants: - Protocol constants (PROTOCOL_JSONRPC, PROTOCOL_HTTP_JSON, PROTOCOL_GRPC) @@ -173,7 +173,7 @@ def add_message(self, agent_name=None, process_type=None, content=None): mock_botocore_crt.CRT_SUPPORTED_AUTH_TYPES = [] # Mock A2AAgentInfo - mock_a2a_agent_proxy = ModuleType("sdk.nexent.core.agents.a2a_agent_proxy") + mock_a2a_agent_proxy = ModuleType("sdk.nexent.core.agents.resources.a2a") mock_a2a_agent_proxy_class = MagicMock() setattr(mock_a2a_agent_proxy, "A2AAgentInfo", mock_a2a_agent_proxy_class) @@ -207,7 +207,7 @@ def add_message(self, agent_name=None, process_type=None, content=None): "botocore.args": mock_botocore_args, "botocore.regions": mock_botocore_regions, "botocore.crt": mock_botocore_crt, - "sdk.nexent.core.agents.a2a_agent_proxy": mock_a2a_agent_proxy, + "sdk.nexent.core.agents.resources.a2a": mock_a2a_agent_proxy, "paramiko": MagicMock(), "boto3": MagicMock(), "tiktoken": MagicMock(), @@ -233,48 +233,21 @@ def add_message(self, agent_name=None, process_type=None, content=None): # --------------------------------------------------------------------------- def _load_agent_model_module(): - """Load agent_model module directly.""" - # Use cross-platform path construction - # __file__ is C:\xuyaqi\develop\nexent\test\sdk\core\agents\test_agent_model.py - # We need to go up 5 levels to get to C:\xuyaqi\develop\nexent - project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) - agent_model_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "agent_model.py") - - sys.modules["sdk"] = ModuleType("sdk") - sys.modules["sdk.nexent"] = ModuleType("sdk.nexent") - sys.modules["sdk.nexent.core"] = ModuleType("sdk.nexent.core") - sys.modules["sdk.nexent.core.agents"] = ModuleType("sdk.nexent.core.agents") - models_package = ModuleType("sdk.nexent.core.models") - models_package.__path__ = [] - capacity_budget = ModuleType("sdk.nexent.core.models.capacity_budget") - class ContextBudgetSnapshot(BaseModel): - fingerprint: str = "budget" - capacity_budget.ContextBudgetSnapshot = ContextBudgetSnapshot - sys.modules["sdk.nexent.core.models"] = models_package - sampling_spec = importlib.util.spec_from_file_location( - "sdk.nexent.core.models.sampling", - os.path.join(project_root, "sdk", "nexent", "core", "models", "sampling.py"), - ) - sampling_module = importlib.util.module_from_spec(sampling_spec) - sampling_spec.loader.exec_module(sampling_module) - sys.modules["sdk.nexent.core.models.sampling"] = sampling_module - sys.modules["sdk.nexent.core.models.capacity_budget"] = capacity_budget - context_package = ModuleType("sdk.nexent.core.agents.context") - context_package.__path__ = [] - context_models = ModuleType("sdk.nexent.core.agents.context.models") - class ContextItemInput(BaseModel): - id: str = "item" - context_models.ContextItemInput = ContextItemInput - sys.modules["sdk.nexent.core.agents.context"] = context_package - sys.modules["sdk.nexent.core.agents.context.models"] = context_models - - spec = importlib.util.spec_from_file_location("sdk.nexent.core.agents.agent_model", agent_model_path) - module = importlib.util.module_from_spec(spec) - module.__package__ = "sdk.nexent.core.agents" - sys.modules["sdk.nexent.core.agents.agent_model"] = module - - spec.loader.exec_module(module) - return module + """Load canonical configuration and run contracts for shared assertions.""" + from importlib import import_module + + sdk = Path(__file__).resolve().parents[4] / "sdk" + for name in ("nexent", "nexent.core", "nexent.core.agents", "nexent.core.agents.contracts", "nexent.core.models"): + if name not in sys.modules: + package = ModuleType(name) + package.__path__ = [str(sdk / name.replace(".", "/"))] + sys.modules[name] = package + sys.modules["nexent.core.agents.resources.a2a"] = _module_mocks["sdk.nexent.core.agents.resources.a2a"] + configuration = import_module("nexent.core.agents.contracts.config") + running = import_module("nexent.core.agents.contracts.run") + exports = {name: value for name, value in vars(configuration).items() if not name.startswith("_")} + exports.update(AgentHistory=running.AgentHistory, AgentRunInfo=running.AgentRunInfo) + return types.SimpleNamespace(**exports) agent_model_module = _load_agent_model_module() @@ -903,7 +876,7 @@ def test_certificate_preference_reaches_proxy(preference): config = agent_model_module.ExternalA2AAgentConfig( agent_id="peer", name="Peer", url="https://peer", **preference) config.to_a2a_agent_info() - proxy_info = _module_mocks["sdk.nexent.core.agents.a2a_agent_proxy"].A2AAgentInfo + proxy_info = _module_mocks["sdk.nexent.core.agents.resources.a2a"].A2AAgentInfo assert proxy_info.call_args.kwargs["ssl_verify"] is preference.get("ssl_verify", True) proxy_info.reset_mock() @@ -1067,7 +1040,7 @@ def test_external_a2a_agent_config_to_a2a_agent_info(self): agent_info = config.to_a2a_agent_info() # Verify the mock was called with correct arguments - mock_a2a_agent_proxy_class = _module_mocks["sdk.nexent.core.agents.a2a_agent_proxy"].A2AAgentInfo + mock_a2a_agent_proxy_class = _module_mocks["sdk.nexent.core.agents.resources.a2a"].A2AAgentInfo mock_a2a_agent_proxy_class.assert_called_once() call_kwargs = mock_a2a_agent_proxy_class.call_args[1] assert call_kwargs["agent_id"] == "convert-agent" diff --git a/test/sdk/core/agents/test_agent_prompt_composer.py b/test/sdk/core/agents/test_agent_prompt_composer.py index 7aeadcdd9d..9b15f93376 100644 --- a/test/sdk/core/agents/test_agent_prompt_composer.py +++ b/test/sdk/core/agents/test_agent_prompt_composer.py @@ -259,7 +259,7 @@ def test_ut_sdk_dpr_003_two_context_snapshots_do_not_share_resources(): def test_ut_sdk_dpr_001_nested_manager_call_uses_manager_role(): """UT-SDK-DPR-001: a called manager retains its own task/report module.""" - from nexent.core.agents.core_agent import CoreAgent + from nexent.core.agents.cores.smolagents.core_agent import CoreAgent agent = CoreAgent.__new__(CoreAgent) agent.workspace_path = None @@ -268,7 +268,7 @@ def test_ut_sdk_dpr_001_nested_manager_call_uses_manager_role(): agent.prompt_templates = AgentPromptComposer(_bundle(role="manager")).compatibility_templates() agent.run = MagicMock(return_value='{"answer":"done"}') agent.observer = MagicMock() - with patch("nexent.core.agents.core_agent.RunResult", type("UnusedRunResult", (), {})): + with patch("nexent.core.agents.cores.smolagents.core_agent.RunResult", type("UnusedRunResult", (), {})): report = agent(task='Return JSON with only the key "answer".') assert agent.run.call_args.args[0] == 'Return JSON with only the key "answer".' diff --git a/test/sdk/core/agents/test_canonical_agent_imports.py b/test/sdk/core/agents/test_canonical_agent_imports.py new file mode 100644 index 0000000000..42a7d80f10 --- /dev/null +++ b/test/sdk/core/agents/test_canonical_agent_imports.py @@ -0,0 +1,167 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Canonical Agent import contracts after the explicit compatibility removal.""" + +import ast +import importlib +import json +import pickle +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[4] +REMOVED = [ + "core_adapters", "agent_model", "clarification", "run_agent", "nexent_agent", + "core_agent", "output_protocol", "plan_repo", "verification", "tool_fastpath", + "managed_mcp", "subagent_wrapper", "a2a_agent_proxy", "tool_parameters", + "tool_user_context", "sandbox_workspace", "context_input", "summary_cache", + "context.runtime", "sandbox_tls", "sandbox_tls_bootstrap", "summary_config", "agent_context", +] +CANONICAL = [ + "contracts.config", "contracts.run", "contracts.adapter", "contracts.resources", + "contracts.output", "contracts.context", "contracts.clarification", "factory", + "orchestration.entry", "orchestration.children", "resources.provider", "resources.mcp", + "resources.subagents", "resources.a2a", "output.answer", "cores.smolagents.adapter", + "cores.smolagents.agent", "cores.smolagents.core_agent", "cores.smolagents.context", + "cores.jiuwen.adapter", "cores.jiuwen.model", "cores.jiuwen.protocol", "cores.jiuwen.events", + "sandbox.config", "sandbox.execution", "sandbox.tls", "sandbox.tls_bootstrap", +] + + +def test_project_imports_do_not_depend_on_removed_agent_paths(): + removed = ["nexent.core.agents." + name for name in REMOVED] + removed += ["nexent.core.context_runtime", "nexent.core.agent_runtime_support"] + failures = [] + for folder in [ROOT / "sdk/nexent", ROOT / "sdk/benchmark", ROOT / "backend"]: + for path in folder.rglob("*.py"): + if ".venv" in path.parts or "__pycache__" in path.parts: + continue + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8-sig"))): + if isinstance(node, ast.ImportFrom): + modules = [(node.module or "")] + [(node.module or "") + "." + item.name for item in node.names] + elif isinstance(node, ast.Import): + modules = [item.name for item in node.names] + else: + continue + if any(module == old or module.startswith(old + ".") for module in modules for old in removed): + failures.append((str(path), node.lineno, modules)) + assert failures == [] + + +def isolated(code): + result = subprocess.run([sys.executable, "-I", "-c", + "import sys;sys.path.insert(0," + repr(str(ROOT / "sdk")) + ");\n" + code], + capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + return result.stdout + + +@pytest.mark.parametrize("relative", CANONICAL) +def test_canonical_module_is_loaded_from_its_own_file(relative): + module = importlib.import_module("nexent.core.agents." + relative) + expected = ROOT / "sdk/nexent/core/agents" / (relative.replace(".", "/") + ".py") + assert Path(module.__file__).resolve() == expected.resolve() + assert module.__name__ == "nexent.core.agents." + relative + + +def test_removed_modules_and_historical_pickle_names_are_unavailable(): + names = ["nexent.core.agents." + name for name in REMOVED] + names += ["nexent.core.context_runtime", "nexent.core.agent_runtime_support"] + isolated(f''' +import importlib, importlib.util, pickle +for name in {names!r}: + try: + spec = importlib.util.find_spec(name) + except ModuleNotFoundError: + spec = None + assert spec is None, name + try: + importlib.import_module(name) + except ModuleNotFoundError as error: + assert name == error.name or name.startswith(error.name + '.'), (name, error.name) + else: + raise AssertionError(name) +for module, symbol in [('nexent.core.agents.agent_model','AgentConfig'), + ('nexent.core.agent_runtime_support.output','RunResult'), + ('nexent.core.agents.sandbox','SandboxConfig')]: + try: + pickle.loads(f'c{{module}}\\n{{symbol}}\\n.'.encode()) + except (ImportError, AttributeError): + pass + else: + raise AssertionError((module, symbol)) +''') + + +def test_canonical_contract_serialization_validation_and_answer_errors(): + from nexent.core.agents.contracts.config import AgentConfig + from nexent.core.agents.contracts.output import RunResult + from nexent.core.agents.output.answer import AnswerBoundaryError, normalize_public_answer + + result = RunResult(turn_id="turn", status="completed", end_reason="answer", final_content="Done") + assert RunResult.model_validate_json(result.model_dump_json()) == result + assert pickle.loads(pickle.dumps(result)) == result + assert AgentConfig.__module__ == "nexent.core.agents.contracts.config" + with pytest.raises(ValidationError): + RunResult(turn_id="turn", status="failed", end_reason="error", final_content="False success") + with pytest.raises(AnswerBoundaryError, match="JIUWEN_FINAL_OUTPUT_INVALID"): + normalize_public_answer("private") + + +def test_factory_extension_registration_dispatch_and_rejection(monkeypatch): + from nexent.core.agents.contracts.adapter import AgentCoreAdapter, UnsupportedAgentCoreConfig + from nexent.core.agents.factory import AgentCoreFactory + + monkeypatch.setattr(AgentCoreFactory, "_adapters", {}) + class Extension(AgentCoreAdapter): + core_name = "fixture" + @classmethod + def capabilities(cls): + return frozenset() + def validate(self): + pass + def run(self): + return "extension result" + AgentCoreFactory.register("fixture", Extension) + info = SimpleNamespace(agent_config=SimpleNamespace(agent_core="fixture")) + assert AgentCoreFactory.create(info).run() == "extension result" + with pytest.raises(ValueError, match="already registered"): + AgentCoreFactory.register("fixture", Extension) + with pytest.raises(UnsupportedAgentCoreConfig, match="Unknown agent core"): + AgentCoreFactory.create(SimpleNamespace(agent_config=SimpleNamespace(agent_core="missing"))) + + +@pytest.mark.parametrize("name,adapter,error", [("Invalid-name", object, ValueError), ("fixture", object, TypeError)]) +def test_factory_rejects_invalid_registration(name, adapter, error): + from nexent.core.agents.factory import AgentCoreFactory + with pytest.raises(error): + AgentCoreFactory.register(name, adapter) + + +def test_optional_jiuwen_dependency_does_not_affect_default_core_or_fall_back(): + output = isolated(''' +import importlib.abc,json +class BlockJiuwen(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == 'openjiuwen' or fullname.startswith('openjiuwen.'): + raise ModuleNotFoundError('optional Jiuwen unavailable', name=fullname) +sys.meta_path.insert(0, BlockJiuwen()) +from nexent.core.agents.factory import AgentCoreFactory +from types import SimpleNamespace +assert not any(name.startswith('openjiuwen') for name in sys.modules) +info = SimpleNamespace(agent_config=SimpleNamespace(agent_core='smolagents')) +assert AgentCoreFactory.create(info, smolagents_executor=lambda value:'ok').run() == 'ok' +info.agent_config.agent_core='jiuwen' +try: + AgentCoreFactory.create(info).prepare() +except ModuleNotFoundError as error: + assert error.name.startswith('openjiuwen') +else: + raise AssertionError('Jiuwen must not silently fall back') +print(json.dumps({'status':'PASS'})) +''') + assert json.loads(output.splitlines()[-1])["status"] == "PASS" diff --git a/test/sdk/core/agents/test_clarification_form.py b/test/sdk/core/agents/test_clarification_form.py index 9c90e756cc..f58c272062 100644 --- a/test/sdk/core/agents/test_clarification_form.py +++ b/test/sdk/core/agents/test_clarification_form.py @@ -1,8 +1,9 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import json from copy import deepcopy import pytest -from nexent.core.agents.clarification import ClarificationForm +from nexent.core.agents.contracts.clarification import ClarificationForm @pytest.fixture diff --git a/test/sdk/core/agents/test_clarification_protocol.py b/test/sdk/core/agents/test_clarification_protocol.py index d8d7f39b32..1fd7e52791 100644 --- a/test/sdk/core/agents/test_clarification_protocol.py +++ b/test/sdk/core/agents/test_clarification_protocol.py @@ -1,13 +1,8 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Terminal clarification parsing rejects the complete block before execution.""" import pytest -from nexent.core.agents.clarification import ( - choose_clarification_tool_name, - render_question_text, -) -from nexent.core.agents.output_protocol import ( - ModelOutputProtocolError, - extract_clarification_form, -) +from nexent.core.agents.contracts.clarification import choose_clarification_tool_name, render_question_text +from nexent.core.agents.cores.smolagents.output_protocol import ModelOutputProtocolError, extract_clarification_form QUESTIONS = "[{'id': 'scope', 'type': 'text', 'title': 'Which region?'}]" diff --git a/test/sdk/core/agents/test_clarification_runtime.py b/test/sdk/core/agents/test_clarification_runtime.py index 0101bc1763..5c78f4d3df 100644 --- a/test/sdk/core/agents/test_clarification_runtime.py +++ b/test/sdk/core/agents/test_clarification_runtime.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Exercise the real CodeAgent loop with a deterministic model and executor.""" import json import threading @@ -8,8 +9,9 @@ from smolagents import Tool from smolagents.models import ChatMessage, Model -from nexent.core.agents.context import ContextManager, ContextManagerConfig, ManagedContextRuntime -from nexent.core.agents.core_agent import CoreAgent +from nexent.core.agents.context import ContextManager, ContextManagerConfig +from nexent.core.agents.cores.smolagents.context import ManagedContextRuntime +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent from nexent.core.concurrency.cancellation import RunTerminated from nexent.core.utils.observer import MessageObserver @@ -172,9 +174,10 @@ def test_cancel_exception_bypasses_ordinary_exception_handlers(): @pytest.mark.asyncio @pytest.mark.parametrize("invalid_output", [False, True]) async def test_worker_drains_card_or_terminal_protocol_error(monkeypatch, invalid_output): - from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo - from nexent.core.agents.nexent_agent import NexentAgent - from nexent.core.agents.run_agent import agent_run + from nexent.core.agents.contracts.config import AgentConfig + from nexent.core.agents.contracts.run import AgentRunInfo + from nexent.core.agents.cores.smolagents.agent import NexentAgent + from nexent.core.agents.orchestration.entry import agent_run from nexent.core.concurrency import LanePolicy, ThreadManager model = ScriptedModel(["bare answer"] * 3 if invalid_output else [f"思考:请用户补充信息。\n代码:\n{CODE}"]) diff --git a/test/sdk/core/agents/test_context_canonical_imports.py b/test/sdk/core/agents/test_context_canonical_imports.py new file mode 100644 index 0000000000..82d0d107de --- /dev/null +++ b/test/sdk/core/agents/test_context_canonical_imports.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Removed context facades no longer participate in canonical imports.""" +import importlib +import pickle + +import pytest + + +@pytest.mark.parametrize("module,symbol", [ + ("nexent.core.agents.context.manager", "ContextManager"), + ("nexent.core.agents.context.config", "ContextManagerConfig"), + ("nexent.core.agents.cores.smolagents.context", "ManagedContextRuntime"), +]) +def test_context_owner_and_canonical_pickle(module, symbol): + value = getattr(importlib.import_module(module), symbol) + assert value.__module__ == module + assert pickle.loads(pickle.dumps(value)) is value diff --git a/test/sdk/core/agents/test_context_compatibility_imports.py b/test/sdk/core/agents/test_context_compatibility_imports.py deleted file mode 100644 index e5dc957cbb..0000000000 --- a/test/sdk/core/agents/test_context_compatibility_imports.py +++ /dev/null @@ -1,64 +0,0 @@ -"""One-release compatibility coverage for moved context imports.""" - -from __future__ import annotations - -import os -from pathlib import Path -import subprocess -import sys - - -def _run(code: str) -> None: - environment = os.environ.copy() - sdk_root = Path(__file__).resolve().parents[4] / "sdk" - environment["PYTHONPATH"] = os.pathsep.join( - [str(sdk_root), environment.get("PYTHONPATH", "")] - ) - subprocess.run([sys.executable, "-c", code], check=True, env=environment) - - -def test_agent_context_package_warns_and_reexports_canonical_symbols(): - _run( - """ -import warnings -warnings.simplefilter('always', DeprecationWarning) -with warnings.catch_warnings(record=True) as caught: - from nexent.core.agents.context import ContextManager as canonical - from nexent.core.agents.agent_context import ContextManager as compatible - from nexent.core.agents.agent_context.manager import ContextManager as submodule_compatible -assert compatible is canonical -assert submodule_compatible is canonical -assert any('agent_context is deprecated' in str(item.message) for item in caught) -assert any('v2.4.0' in str(item.message) for item in caught) -""" - ) - - -def test_summary_config_warns_and_reexports_canonical_config(): - _run( - """ -import warnings -warnings.simplefilter('always', DeprecationWarning) -with warnings.catch_warnings(record=True) as caught: - from nexent.core.agents.context import ContextManagerConfig as canonical - from nexent.core.agents.summary_config import ContextManagerConfig as compatible -assert compatible is canonical -assert any('summary_config is deprecated' in str(item.message) for item in caught) -assert any('v2.4.0' in str(item.message) for item in caught) -""" - ) - - -def test_managed_runtime_warns_and_reexports_canonical_runtime(): - _run( - """ -import warnings -warnings.simplefilter('always', DeprecationWarning) -with warnings.catch_warnings(record=True) as caught: - from nexent.core.agents.context import ManagedContextRuntime as canonical - from nexent.core.context_runtime.managed import ManagedContextRuntime as compatible -assert compatible is canonical -assert any('context_runtime.managed is deprecated' in str(item.message) for item in caught) -assert any('v2.4.0' in str(item.message) for item in caught) -""" - ) diff --git a/test/sdk/core/agents/test_context_directory_boundaries.py b/test/sdk/core/agents/test_context_directory_boundaries.py new file mode 100644 index 0000000000..ccd38397b1 --- /dev/null +++ b/test/sdk/core/agents/test_context_directory_boundaries.py @@ -0,0 +1,339 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Context contracts and native bridges keep identities and message semantics.""" + +import ast +import importlib +import json +import pickle +import subprocess +import sys +from dataclasses import asdict +from pathlib import Path +from types import SimpleNamespace + +import pytest +from nexent.core.agents.context.config import ContextManagerConfig +from nexent.core.agents.context.llm_summary import LLMSummary +from nexent.core.agents.context.manager import ContextManager +from nexent.core.agents.context.step_renderer import StepRenderer +from nexent.core.agents.contracts.context import ( + ContextInput, + ContextItemInput, + ContextMessage, + ContextToolView, + FinalContext, +) +from nexent.core.agents.cores.smolagents.context import ( + ManagedContextRuntime, + adapt_context_model, +) +from nexent.core.agents.cores.smolagents.context_contracts import ( + native_context_messages, + native_context_rebuild, +) +from nexent.core.utils.observer import MessageObserver +from pydantic import ValidationError +from smolagents.memory import AgentMemory, TaskStep +from smolagents.models import ChatMessage + +ROOT = Path(__file__).resolve().parents[4] + + +@pytest.mark.parametrize("module,names", [ + ("contracts.context", ("ContextInput", "ContextItemInput", "ContextItemType")), + ("context.summary_cache", ("CompressionCallRecord",)), + ("contracts.clarification", ("ClarificationOption", "ClarificationQuestion", "ClarificationForm")), +]) +def test_context_types_are_owned_and_pickled_by_canonical_modules(module, names): + owner = importlib.import_module("nexent.core.agents." + module) + for name in names: + symbol = getattr(owner, name) + assert symbol.__module__ == owner.__name__ + assert pickle.loads(pickle.dumps(symbol)) is symbol + + +def test_evidence_final_context_and_native_protocols_have_separate_owners(): + from nexent.core.agents.contracts import context as data + from nexent.core.agents.cores.smolagents import context_contracts as native + assert data.ContextEvidence.__module__ == data.__name__ + assert data.FinalContext.__module__ == data.__name__ + assert native.ContextRuntime.__module__ == native.__name__ + assert native.UnconfiguredContextRuntime.__module__ == native.__name__ + assert ManagedContextRuntime.__module__ == "nexent.core.agents.cores.smolagents.context" + + +def test_authorized_input_detaches_and_rejects_invalid_payloads(): + item = ContextItemInput(id="source", type="system", content={"text": "policy"}) + snapshot = ContextInput(items=(item,)) + assert pickle.loads(pickle.dumps(snapshot)) == snapshot + item.content["text"] = "modified" + assert snapshot.items[0].content == {"text": "policy"} + with pytest.raises(TypeError, match="immutable tuples"): + ContextInput(items=[]) + with pytest.raises(ValidationError, match="JSON serializable"): + ContextItemInput(id="invalid", type="system", content={"text": object()}) + + +def test_common_context_source_imports_no_native_memory_types_or_core_implementations(): + context = ROOT / "sdk/nexent/core/agents/context" + files = [ + file + for file in context.rglob("*.py") + if file.name not in {"compatibility.py", "runtime.py"} + ] + files += [ + ROOT / "sdk/nexent/core/agents/contracts/context.py", + ROOT / "sdk/nexent/core/agents/contracts/clarification.py", + ] + for file in files: + for node in ast.walk(ast.parse(file.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom): + assert "smolagents" not in (node.module or ""), file + assert "cores." not in (node.module or ""), file + schema = (ROOT / "sdk/nexent/core/agents/contracts/clarification.py").read_text( + encoding="utf-8" + ) + assert "load_prompt" not in schema + + +def test_neutral_cm_runs_with_native_context_bridge_imports_rejected(): + code = f""" +import sys, importlib.abc +sys.path.insert(0, {str(ROOT / "sdk")!r}) +import nexent +class NoNativeMemory(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname in {{'nexent.core.agents.cores.smolagents.context', + 'nexent.core.agents.context.compatibility'}}: + raise AssertionError('neutral path imported native memory: ' + fullname) +sys.meta_path.insert(0, NoNativeMemory()) +from nexent.core.agents.context.manager import ContextManager +from nexent.core.agents.contracts.context import ContextItemInput +from nexent.core.agents.context.models import ContextItem +manager = ContextManager() +run = manager.prepare_items_context('SYSTEM', [ContextItemInput(id='history', type='system', content={{'text':'FACT'}})]) +current = ContextItem.from_input(ContextItemInput(id='task', type='current_task', content={{'text':'CURRENT'}})) +result = manager.assemble_context_items(model=None, run_context=run, current_items=[current]) +assert str(result.messages).count('CURRENT') == 1 +assert 'FACT' in str(result.messages) +print('NEUTRAL_CM_PASS') +""" + result = subprocess.run( + [sys.executable, "-I", "-c", code], capture_output=True, text=True, timeout=90 + ) + assert result.returncode == 0, result.stderr + assert "NEUTRAL_CM_PASS" in result.stdout + + +@pytest.mark.parametrize("supports_max_tokens", [True, False]) +@pytest.mark.parametrize("raises", [True, False]) +def test_native_summary_port_preserves_parameters_message_payload_and_restores_model_state( + supports_max_tokens, raises +): + observer = MessageObserver() + budget = object() + original_body = {"custom": 1, "chat_template_kwargs": {"keep": "value"}} + calls = [] + + class NativeModel: + def __init__(self): + self.observer, self.context_budget_snapshot, self.extra_body = ( + observer, + budget, + original_body, + ) + + def invoke(self, messages, parameters): + assert all(isinstance(message, ChatMessage) for message in messages) + assert [message.role.value for message in messages] == ["system", "user"] + assert messages[0].content[0]["text"] == "SUMMARY_SYSTEM" + assert "SOURCE_FACT" in messages[1].content[0]["text"] + assert self.observer is not observer + assert self.extra_body["chat_template_kwargs"] == { + "keep": "value", + "enable_thinking": False, + } + assert (self.context_budget_snapshot is None) is supports_max_tokens + calls.append(parameters) + if raises: + raise RuntimeError("controlled failure") + return SimpleNamespace( + content="SUMMARY", + token_usage=SimpleNamespace(input_tokens=9, output_tokens=2), + ) + + if supports_max_tokens: + + def invoke(self, messages, *, stop_sequences, max_tokens=None): + return self.invoke( + messages, {"stop_sequences": stop_sequences, "max_tokens": max_tokens} + ) + else: + + def invoke(self, messages, *, stop_sequences): + return self.invoke(messages, {"stop_sequences": stop_sequences}) + + NativeModel.__call__ = invoke + model = NativeModel() + config = ContextManagerConfig(summary_system_prompt="SUMMARY_SYSTEM") + summary = LLMSummary(config, StepRenderer(config)).generate_summary( + "SOURCE_FACT", + adapt_context_model(model), + max_output_tokens=19, + ) + assert len(calls) == 1 + assert calls[0] == ( + {"stop_sequences": [], "max_tokens": 19} + if supports_max_tokens + else {"stop_sequences": []} + ) + assert summary.summary_text == (None if raises else "SUMMARY") + assert model.observer is observer and model.context_budget_snapshot is budget + assert model.extra_body is original_body + + +def test_common_summary_supplies_neutral_messages_without_native_conversion(): + calls = [] + + def neutral_model(messages, **parameters): + assert all(isinstance(message, ContextMessage) for message in messages) + calls.append((messages, parameters)) + return SimpleNamespace(content="RESULT") + + config = ContextManagerConfig() + result = LLMSummary(config, StepRenderer(config)).generate_summary( + "FACT", neutral_model + ) + assert result.summary_text == "RESULT" + assert len(calls) == 1 + + +def test_native_lifecycle_uses_neutral_assembly_without_legacy_manager_memory_facades( + monkeypatch, +): + manager = ContextManager() + + def forbidden(*args, **kwargs): + raise AssertionError("production used a legacy memory facade") + + for name in ( + "prepare_run_context", + "assemble_final_context", + "_project_current_run", + "render_memory_messages", + ): + monkeypatch.setattr(manager, name, forbidden) + memory = AgentMemory("SYSTEM") + memory.steps.append(TaskStep(task="CURRENT")) + runtime = ManagedContextRuntime(manager) + runtime.prepare_run(memory=memory, fallback_system_prompt="SYSTEM") + context = runtime.prepare_step(model=None, memory=memory, current_run_start_idx=0) + assert str(context.messages).count("CURRENT") == 1 + assert "SYSTEM" in str(context.messages) + + +def test_native_tool_audit_and_request_messages_preserve_budget_and_fingerprints_without_executable_objects(): + class NativeTool: + name = "read" + + def __str__(self): + return "NativeTool(read)" + + def __call__(self): + raise AssertionError("CM must never execute a tool") + + tools = [NativeTool()] + overlay = [ + ChatMessage(role="system", content=[{"type": "text", "text": "REQUEST_ONLY"}]) + ] + memory = AgentMemory("SYSTEM") + memory.steps.append(TaskStep(task="CURRENT")) + manager = ContextManager() + runtime = ManagedContextRuntime(manager) + runtime.prepare_run(memory=memory, fallback_system_prompt="SYSTEM") + final = runtime.prepare_step( + model=None, + memory=memory, + current_run_start_idx=0, + tools=tools, + request_system_messages=overlay, + ) + assert all( + isinstance(tool, ContextToolView) and not callable(tool) for tool in final.tools + ) + assert json.dumps(final.tools, default=str) == json.dumps(tools, default=str) + assert any(isinstance(message, ContextMessage) for message in final.messages) + legacy_manager = ContextManager() + legacy_run = legacy_manager.prepare_run_context( + memory=memory, fallback_system_prompt="SYSTEM" + ) + legacy = legacy_manager.assemble_final_context( + model=None, + memory=memory, + current_run_start_idx=0, + tools=tools, + request_system_messages=overlay, + run_context=legacy_run, + ) + assert legacy.tools == tools + assert asdict(final.evidence) == asdict(legacy.evidence) + assert [ + getattr(message, "content", message) + for message in native_context_messages(final.messages) + ] == [getattr(message, "content", message) for message in legacy.messages] + + +def test_neutral_rebuild_converts_at_native_boundary_without_mutating_common_context(): + common = FinalContext(messages=[ContextMessage(role="user", content="FACT")]) + native = native_context_rebuild(common) + assert native is not common and native.evidence is common.evidence + assert isinstance(native.messages[0], ChatMessage) + assert isinstance(common.messages[0], ContextMessage) + assert native.messages[0].content == "FACT" + assert native_context_rebuild(native) is native + + +@pytest.mark.parametrize( + "model_id,model_name", [("saved", "alias"), ("", "alias"), (None, None)] +) +def test_native_context_model_keeps_the_original_cache_identity_fallback( + model_id, model_name +): + class PlainModel: + def __call__(self, messages, **kwargs): + return SimpleNamespace(content="") + + model = PlainModel() + model.model_id, model.model_name = model_id, model_name + assert adapt_context_model(model).model_id == ( + model_id or model_name or "PlainModel" + ) + + +def test_native_memory_selection_uses_the_configured_model_and_only_selects_source_blocks(): + from nexent.core.agents.context.long_term_memory_selector import ( + select_long_term_memory, + ) + + calls = [] + + class NativeSelector: + def __call__(self, messages, *, stop_sequences): + assert all(isinstance(message, ChatMessage) for message in messages) + assert stop_sequences == [] + catalog = json.loads(messages[0].content[0]["text"]) + calls.append(catalog) + selections = { + scope: [blocks[0]["id"]] for scope, blocks in catalog["blocks"].items() + } + return SimpleNamespace(content=json.dumps({"selections": selections})) + + selected, audit = select_long_term_memory( + {"saved": "Source fact"}, + task="CURRENT", + target_tokens=100, + model=adapt_context_model(NativeSelector()), + chars_per_token=1.5, + ) + assert selected == {"saved": "Source fact"} + assert audit["outcome"] == "selected" and len(calls) == 1 diff --git a/test/sdk/core/agents/test_context_helper_contracts.py b/test/sdk/core/agents/test_context_helper_contracts.py index d7744c6e9a..9b34fab458 100644 --- a/test/sdk/core/agents/test_context_helper_contracts.py +++ b/test/sdk/core/agents/test_context_helper_contracts.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from dataclasses import dataclass from enum import Enum from types import SimpleNamespace @@ -27,11 +28,8 @@ from nexent.core.agents.context.llm_summary import _strip_code_fences from nexent.core.agents.context.rendering import ContextItemRenderer, ContextItemRenderingError from nexent.core.agents.context.step_renderer import StepRenderer -from nexent.core.context_runtime.contracts import ( - ContextEvidence, - FinalContext, - UnconfiguredContextRuntime, -) +from nexent.core.agents.contracts.context import ContextEvidence, FinalContext +from nexent.core.agents.cores.smolagents.context_contracts import UnconfiguredContextRuntime class _Role(Enum): diff --git a/test/sdk/core/agents/test_context_import_isolation.py b/test/sdk/core/agents/test_context_import_isolation.py index 1519a6879f..33a5fb2ac9 100644 --- a/test/sdk/core/agents/test_context_import_isolation.py +++ b/test/sdk/core/agents/test_context_import_isolation.py @@ -1,17 +1,21 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Import-level isolation tests for ContextManager-on/off paths.""" from __future__ import annotations import subprocess import sys +from pathlib import Path def _run_isolation_check(module_name: str) -> None: code = f""" import sys +sys.path.insert(0, {str(Path(__file__).resolve().parents[4] / "sdk")!r}) import {module_name} forbidden = [ 'nexent.core.agents.context.manager', - 'nexent.core.agents.context.runtime', + 'nexent.core.agents.cores.smolagents.context', ] loaded = [name for name in forbidden if name in sys.modules] assert not loaded, loaded @@ -20,16 +24,16 @@ def _run_isolation_check(module_name: str) -> None: def test_agent_model_import_does_not_load_context_manager_or_runtimes(): - _run_isolation_check("nexent.core.agents.agent_model") + _run_isolation_check("nexent.core.agents.contracts.config") def test_nexent_agent_import_does_not_load_context_manager_or_runtimes(): - _run_isolation_check("nexent.core.agents.nexent_agent") + _run_isolation_check("nexent.core.agents.cores.smolagents.agent") def test_legacy_context_runtime_is_removed(): result = subprocess.run( - [sys.executable, "-c", "import nexent.core.context_runtime.legacy.runtime"], + [sys.executable, "-c", "import nexent.core.agents.cores.smolagents.context_contracts.legacy.runtime"], capture_output=True, text=True, ) diff --git a/test/sdk/core/agents/test_context_input.py b/test/sdk/core/agents/test_context_input.py index ffafc7f2d7..5de1cf591b 100644 --- a/test/sdk/core/agents/test_context_input.py +++ b/test/sdk/core/agents/test_context_input.py @@ -1,9 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Tests for the immutable authorized run snapshot.""" import pytest -from sdk.nexent.core.agents.context import ContextItemInput -from sdk.nexent.core.agents.context_input import ContextInput +from nexent.core.agents.context import ContextItemInput +from nexent.core.agents.contracts.context import ContextInput def test_context_input_freezes_authorized_item_collection(): diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index 0e58faa3df..b688e43b58 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -1,7 +1,7 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ -Unit tests for sdk.nexent.core.agents.core_agent module. +Unit tests for sdk.nexent.core.agents.cores.smolagents.core_agent module. This module tests CoreAgent class and its helper functions: - parse_code_blobs @@ -254,7 +254,7 @@ def _load_core_agent_module(): # __file__ is C:\Project\nexent\test\sdk\core\agents\test_core_agent.py # We need to go up 5 levels to get to C:\Project\nexent project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) - core_agent_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "core_agent.py") + core_agent_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "cores", "smolagents", "core_agent.py") # Create full package hierarchy sys.modules["sdk"] = ModuleType("sdk") @@ -293,27 +293,36 @@ class ContextBudgetSnapshot(BaseModel): token_estimation_mod.msg_token_count = MagicMock(return_value=0) sys.modules["sdk.nexent.core.utils.token_estimation"] = token_estimation_mod - agent_context_mod = ModuleType("sdk.nexent.core.agents.agent_context") + agent_context_mod = ModuleType("sdk.nexent.core.agents.context") + agent_context_mod.__path__ = [os.path.join(project_root, "sdk", "nexent", "core", "agents", "context")] agent_context_mod.ContextManager = MagicMock() agent_context_mod.ContextManagerConfig = MagicMock() - sys.modules["sdk.nexent.core.agents.agent_context"] = agent_context_mod + sys.modules["sdk.nexent.core.agents.context"] = agent_context_mod - context_runtime_pkg = ModuleType("sdk.nexent.core.context_runtime") - context_runtime_contracts_mod = ModuleType("sdk.nexent.core.context_runtime.contracts") + context_runtime_pkg = ModuleType("sdk.nexent.core.agents.cores.smolagents.context_contracts") + context_runtime_contracts_mod = ModuleType("sdk.nexent.core.agents.cores.smolagents.context_contracts") context_runtime_contracts_mod.ContextRuntime = MagicMock() context_runtime_contracts_mod.UnconfiguredContextRuntime = MagicMock() - sys.modules["sdk.nexent.core.context_runtime"] = context_runtime_pkg - sys.modules["sdk.nexent.core.context_runtime.contracts"] = context_runtime_contracts_mod + context_runtime_contracts_mod.native_context_messages = lambda messages: messages + context_runtime_contracts_mod.native_context_rebuild = lambda context: context + sys.modules["sdk.nexent.core.agents.cores.smolagents.context_contracts"] = context_runtime_pkg + sys.modules["sdk.nexent.core.agents.cores.smolagents.context_contracts"] = context_runtime_contracts_mod + sys.modules["sdk.nexent.core.agents.cores.smolagents.context_contracts"] = context_runtime_contracts_mod monitor_mod = ModuleType("sdk.nexent.monitor") monitor_mod.get_monitoring_manager = MagicMock() sys.modules["sdk.nexent.monitor"] = monitor_mod + # Mirror isolated dependency doubles to the canonical implementation namespace. + for key, value in list(sys.modules.items()): + if key.startswith("sdk.nexent"): + sys.modules[key.removeprefix("sdk.")] = value + # Load the module - spec = importlib.util.spec_from_file_location("sdk.nexent.core.agents.core_agent", core_agent_path) + spec = importlib.util.spec_from_file_location("sdk.nexent.core.agents.cores.smolagents.core_agent", core_agent_path) module = importlib.util.module_from_spec(spec) module.__package__ = "sdk.nexent.core.agents" - sys.modules["sdk.nexent.core.agents.core_agent"] = module + sys.modules["sdk.nexent.core.agents.cores.smolagents.core_agent"] = module # Override some functions with mock implementations def mock_truncate_content(content, max_length=1000): @@ -331,7 +340,7 @@ def mock_truncate_content(content, max_length=1000): core_agent_module = _load_core_agent_module() # Import ProcessType and MessageObserver for tests -ProcessType = _module_mocks["sdk.nexent.core.utils.observer"].ProcessType +ProcessType = core_agent_module.ProcessType MessageObserver = _module_mocks["sdk.nexent.core.utils.observer"].MessageObserver @@ -1878,7 +1887,7 @@ class TestRunStreamRealExecution: @pytest.fixture(autouse=True) def _stub_sandbox_context(self, monkeypatch): """Keep executor tests independent of the sandbox's external imports.""" - sandbox_module = ModuleType(f"{core_agent_module.__package__}.sandbox") + sandbox_module = ModuleType("nexent.core.agents.sandbox.execution") sandbox_module._execute_with_tool_context = lambda executor, code: executor(code) monkeypatch.setitem(sys.modules, sandbox_module.__name__, sandbox_module) @@ -2054,6 +2063,10 @@ class RealProcessType: mock_observer.ProcessType = RealProcessType mock_modules['sdk.nexent.core.utils.observer'] = mock_observer + # Match canonical absolute lookup sites used by the migrated implementation. + mock_modules.update({key.removeprefix("sdk."): value for key, value in list(mock_modules.items()) + if key.startswith("sdk.nexent")}) + # Save original modules original_modules = {} for name in mock_modules: @@ -2068,7 +2081,7 @@ class RealProcessType: # Find the core_agent.py file test_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))) - core_agent_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "core_agent.py") + core_agent_path = os.path.join(project_root, "sdk", "nexent", "core", "agents", "cores", "smolagents", "core_agent.py") _register_sdk_consts_package(project_root) # Load the module @@ -2076,7 +2089,7 @@ class RealProcessType: module = importlib.util.module_from_spec(spec) module.__package__ = "sdk.nexent.core.agents" - sys.modules["sdk.nexent.core.agents.core_agent"] = module + sys.modules["sdk.nexent.core.agents.cores.smolagents.core_agent"] = module # Execute spec.loader.exec_module(module) diff --git a/test/sdk/core/agents/test_core_agent_planning.py b/test/sdk/core/agents/test_core_agent_planning.py index e77b844531..ad84a1fb5e 100644 --- a/test/sdk/core/agents/test_core_agent_planning.py +++ b/test/sdk/core/agents/test_core_agent_planning.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Unit tests for the v1.4 planing methods on CoreAgent. Scope is narrow: only the plan-related instance methods @@ -24,7 +26,8 @@ def _mock_module(name): m = ModuleType(name) - m.__path__ = [] + package_path = REPO_ROOT / "sdk" / name.replace(".", "/") + m.__path__ = [str(package_path)] if package_path.is_dir() else [] sys.modules[name] = m return m @@ -108,7 +111,7 @@ def __init__(self, *args, **kwargs): mock_smolagents.handle_agent_output_types = mock_agents.handle_agent_output_types mock_smolagents.CodeAgent = mock_agents.CodeAgent -# -- sdk.nexent sub-tree --------------------------------------------- +# -- nexent sub-tree --------------------------------------------- def _sdk_pkg(name): parts = name.split(".") result = None @@ -125,7 +128,7 @@ def _sdk_pkg(name): return result -agent_model_mod = _sdk_pkg("sdk.nexent.core.agents.agent_model") +agent_model_mod = _sdk_pkg("nexent.core.agents.contracts.config") class _AgentVerificationConfig: @@ -154,7 +157,7 @@ def __init__( agent_model_mod.PROTOCOL_GRPC = "GRPC" # plan_repo stub -plan_repo_mod = _sdk_pkg("sdk.nexent.core.agents.plan_repo") +plan_repo_mod = _sdk_pkg("nexent.core.agents.cores.smolagents.plan_repo") class _PlanRepo: @@ -175,7 +178,7 @@ def save(self, plan_dict, conversation_id=None, user_id=None, status="active"): plan_repo_mod.PlanRepo = _PlanRepo # observer stub -observer_mod = _sdk_pkg("sdk.nexent.core.utils.observer") +observer_mod = _sdk_pkg("nexent.core.utils.observer") class _ProcessType: @@ -204,25 +207,27 @@ def __init__(self): observer_mod.ProcessType = _ProcessType # context_runtime contracts stub -contracts_mod = _sdk_pkg("sdk.nexent.core.context_runtime.contracts") +contracts_mod = _sdk_pkg("nexent.core.agents.cores.smolagents.context_contracts") contracts_mod.UnconfiguredContextRuntime = type("UnconfiguredContextRuntime", (), {}) contracts_mod.ContextRuntime = type("ContextRuntime", (), {}) +contracts_mod.native_context_messages = lambda messages: messages +contracts_mod.native_context_rebuild = lambda context: context # token_estimation stub -token_mod = _sdk_pkg("sdk.nexent.core.utils.token_estimation") +token_mod = _sdk_pkg("nexent.core.utils.token_estimation") token_mod.msg_token_count = lambda *a, **k: 0 # Agent prompt composer stub -prompt_mod = _sdk_pkg("sdk.nexent.core.agents.prompt") +prompt_mod = _sdk_pkg("nexent.core.agents.prompt") prompt_mod.__path__ = [str(REPO_ROOT / "sdk/nexent/core/agents/prompt")] prompt_mod.AgentPromptComposer = MagicMock() # context budget helper stub -budget_mod = _sdk_pkg("sdk.nexent.core.agents.context.budget") +budget_mod = _sdk_pkg("nexent.core.agents.context.budget") budget_mod.message_role = lambda message: getattr(message, "role", "") # verification stub -verification_mod = _sdk_pkg("sdk.nexent.core.agents.verification") +verification_mod = _sdk_pkg("nexent.core.agents.cores.smolagents.verification") class _AutoResult: @@ -276,49 +281,53 @@ class _VerificationResult: verification_mod.render_tool_input_refusal = lambda decision, messages, tool_name: "" # monitor stub -monitor_mod = _sdk_pkg("sdk.nexent.monitor") +monitor_mod = _sdk_pkg("nexent.monitor") monitor_mod.get_monitoring_manager = MagicMock(return_value=MagicMock()) # ---- Load core_agent under controlled sys.modules ----------------- -OUTPUT_PROTOCOL_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "output_protocol.py" -OUTPUT_PROTOCOL_NAME = "sdk.nexent.core.agents.output_protocol" +OUTPUT_PROTOCOL_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "cores" / "smolagents" / "output_protocol.py" +OUTPUT_PROTOCOL_NAME = "nexent.core.agents.cores.smolagents.output_protocol" output_protocol_spec = importlib.util.spec_from_file_location(OUTPUT_PROTOCOL_NAME, OUTPUT_PROTOCOL_PATH) output_protocol_module = importlib.util.module_from_spec(output_protocol_spec) sys.modules[OUTPUT_PROTOCOL_NAME] = output_protocol_module -agents_mod = sys.modules["sdk.nexent.core.agents"] +agents_mod = sys.modules["nexent.core.agents"] # Let relative imports resolve real sibling modules such as clarification. -agents_mod.__path__ = [str(OUTPUT_PROTOCOL_PATH.parent)] +agents_mod.__path__ = [str(REPO_ROOT / "sdk/nexent/core/agents")] agents_mod.output_protocol = output_protocol_module assert output_protocol_spec and output_protocol_spec.loader -sys.modules["sdk.nexent.core"].__path__ = [str(REPO_ROOT / "sdk/nexent/core")] -sys.modules["sdk.nexent.core"].__spec__ = importlib.machinery.ModuleSpec("sdk.nexent.core", loader=None, is_package=True) +sys.modules["nexent.core"].__path__ = [str(REPO_ROOT / "sdk/nexent/core")] +sys.modules["nexent.core"].__spec__ = importlib.machinery.ModuleSpec("nexent.core", loader=None, is_package=True) output_protocol_spec.loader.exec_module(output_protocol_module) -CORE_AGENT_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "core_agent.py" -CORE_AGENT_NAME = "sdk.nexent.core.agents.core_agent" -sys.modules["sdk.nexent.core"].__path__ = [str(REPO_ROOT / "sdk" / "nexent" / "core")] +CORE_AGENT_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "cores" / "smolagents" / "core_agent.py" +CORE_AGENT_NAME = "nexent.core.agents.cores.smolagents.core_agent" +sys.modules["nexent.core"].__path__ = [str(REPO_ROOT / "sdk" / "nexent" / "core")] -# ``sdk.nexent.core.agents`` is an isolated shim with an empty ``__path__`` +# ``nexent.core.agents`` is an isolated shim with an empty ``__path__`` # above, so Python cannot discover sibling modules during relative imports. # Register the real MCP error helper before loading ``core_agent.py``. MCP_ERRORS_PATH = REPO_ROOT / "sdk" / "nexent" / "consts" / "mcp_errors.py" -MCP_ERRORS_NAME = "sdk.nexent.consts.mcp_errors" +MCP_ERRORS_NAME = "nexent.consts.mcp_errors" mcp_errors_spec = importlib.util.spec_from_file_location(MCP_ERRORS_NAME, MCP_ERRORS_PATH) mcp_errors_module = importlib.util.module_from_spec(mcp_errors_spec) sys.modules[MCP_ERRORS_NAME] = mcp_errors_module -consts_mod = sys.modules.setdefault("sdk.nexent.consts", ModuleType("sdk.nexent.consts")) +consts_mod = sys.modules.setdefault("nexent.consts", ModuleType("nexent.consts")) consts_mod.__path__ = [str(REPO_ROOT / "sdk" / "nexent" / "consts")] consts_mod.mcp_errors = mcp_errors_module assert mcp_errors_spec is not None assert mcp_errors_spec.loader is not None mcp_errors_spec.loader.exec_module(mcp_errors_module) -sys.modules["sdk.nexent.core.utils"].__path__ = [str(REPO_ROOT / "sdk" / "nexent" / "core" / "utils")] +sys.modules["nexent.core.utils"].__path__ = [str(REPO_ROOT / "sdk" / "nexent" / "core" / "utils")] spec = importlib.util.spec_from_file_location(CORE_AGENT_NAME, CORE_AGENT_PATH) core_agent_module = importlib.util.module_from_spec(spec) sys.modules[CORE_AGENT_NAME] = core_agent_module agents_mod.core_agent = core_agent_module assert spec and spec.loader +sys.modules["nexent.core.agents.contracts.config"] = agent_model_mod +sys.modules["nexent.core.agents.cores.smolagents.verification"] = verification_mod +sys.modules["nexent.core.agents.cores.smolagents.plan_repo"] = plan_repo_mod +sys.modules["nexent.core.agents.cores.smolagents.output_protocol"] = output_protocol_module spec.loader.exec_module(core_agent_module) # The shim is needed only while loading the isolated CoreAgent module. Remove diff --git a/test/sdk/core/agents/test_core_child_dispatch.py b/test/sdk/core/agents/test_core_child_dispatch.py index 487ba8d8d8..9e6505b1c0 100644 --- a/test/sdk/core/agents/test_core_child_dispatch.py +++ b/test/sdk/core/agents/test_core_child_dispatch.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Child selection, snapshot isolation and adapter ownership contracts.""" from concurrent.futures import Future @@ -7,10 +8,11 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig -from nexent.core.agents.core_adapters.children import child_run_info, invoke_child -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.core_adapters.smolagents import SmolagentsAdapter +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.orchestration.children import child_run_info, invoke_child +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.cores.smolagents.adapter import SmolagentsAdapter from nexent.core.concurrency import RunCancellationScope from nexent.core.utils.observer import MessageObserver @@ -49,7 +51,7 @@ def test_child_factory_selects_core_and_always_closes(mocker, failure): adapter.run.return_value = "child-result" if failure: getattr(adapter, failure).side_effect = RuntimeError("controlled failure") - factory = mocker.patch("nexent.core.agents.core_adapters.children.AgentCoreFactory.create", return_value=adapter) + factory = mocker.patch("nexent.core.agents.factory.AgentCoreFactory.create", return_value=adapter) if failure: with pytest.raises(RuntimeError, match="controlled failure"): invoke_child(info, "child-task") @@ -62,7 +64,7 @@ def test_child_factory_selects_core_and_always_closes(mocker, failure): @pytest.mark.parametrize("task", ["", " ", None, 1]) def test_empty_or_non_string_child_task_allocates_nothing(mocker, task): - factory = mocker.patch("nexent.core.agents.core_adapters.children.AgentCoreFactory.create") + factory = mocker.patch("nexent.core.agents.factory.AgentCoreFactory.create") with pytest.raises(ValueError): invoke_child(Mock(), task) factory.assert_not_called() diff --git a/test/sdk/core/agents/test_direct_prompt_resources.py b/test/sdk/core/agents/test_direct_prompt_resources.py index 831675f3fa..218ec4e6ad 100644 --- a/test/sdk/core/agents/test_direct_prompt_resources.py +++ b/test/sdk/core/agents/test_direct_prompt_resources.py @@ -212,7 +212,7 @@ def test_ut_sdk_fps_012_tool_policy_registry_is_active_not_a_prompt_alias(): assert set(result.execution_tools) == {"visible", "hidden"} assert result.audit["policy_version"] == "p1" assert "prompt_registry" in (ROOT / "backend/management/services/agent/preparation/compiler.py").read_text(encoding="utf-8") - assert "prompt_registry" in (ROOT / "sdk/nexent/core/agents/nexent_agent.py").read_text(encoding="utf-8") + assert "prompt_registry" in (ROOT / "sdk/nexent/core/agents/cores/smolagents/agent.py").read_text(encoding="utf-8") @pytest.mark.parametrize("source", ["Heading\n", "\nHeading\n\n", "Text {{ value }}\n"]) diff --git a/test/sdk/core/agents/test_fixed_system_prompts.py b/test/sdk/core/agents/test_fixed_system_prompts.py index 67b2498a2f..68b8cfe559 100644 --- a/test/sdk/core/agents/test_fixed_system_prompts.py +++ b/test/sdk/core/agents/test_fixed_system_prompts.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """D1 contracts for SDK-owned fixed system prompts.""" from pathlib import Path @@ -50,7 +51,7 @@ def test_ut_sdk_dpr_012_fixed_prompt_resource_is_packaged(): @pytest.mark.parametrize("language", ["zh", "en"]) def test_ut_sdk_dpr_012_fixed_hitl_prompt_matches_approved_policy(language): """UT-SDK-DPR-012: SDK HITL text matches the approved bilingual policy.""" - from nexent.core.agents.clarification import clarification_policy + from nexent.core.agents.prompt.clarification import clarification_policy from nexent.core.prompts import load_prompt expected = ("### 向用户澄清\n" if language == "zh" else "### Clarifying with the User\n") + EXPECTED_CLARIFICATION[language] @@ -81,7 +82,7 @@ def test_ut_sdk_dpr_012_summary_defaults_and_explicit_overrides(): def test_ut_sdk_dpr_012_verifier_uses_sdk_fixed_prompt(): """UT-SDK-DPR-012: final verification reads its system prompt from SDK resources.""" - from nexent.core.agents.verification import VerificationController + from nexent.core.agents.cores.smolagents.verification import VerificationController from nexent.core.prompts import load_prompt controller = VerificationController.__new__(VerificationController) diff --git a/test/sdk/core/agents/test_guardrail_checkpoints.py b/test/sdk/core/agents/test_guardrail_checkpoints.py index 167482d312..03f440024d 100644 --- a/test/sdk/core/agents/test_guardrail_checkpoints.py +++ b/test/sdk/core/agents/test_guardrail_checkpoints.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Deterministic scenarios for guardrail checkpoints ② (tool output) and ③ (tool input). Checkpoint ① intercepts a keyword the user types this turn, so ②/③ only fire when the @@ -10,14 +11,10 @@ from unittest.mock import MagicMock import pytest -from nexent.core.agents.agent_model import ( - AgentVerificationConfig, - GuardrailConfig, - GuardrailRule, -) -from nexent.core.agents.core_agent import CoreAgent, NonterminalThoughtTurn, ToolInputBlockedError +from nexent.core.agents.contracts.config import AgentVerificationConfig, GuardrailConfig, GuardrailRule +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent, NonterminalThoughtTurn, ToolInputBlockedError from nexent.consts.mcp_errors import MCPToolTimeoutError -from nexent.core.agents.verification import VerificationController +from nexent.core.agents.cores.smolagents.verification import VerificationController KEYWORD = "机密信息" PATTERN = KEYWORD @@ -187,11 +184,7 @@ def test_guardrail_wrap_tools_no_engine_is_noop(): import threading as _threading -from nexent.core.agents.output_protocol import ( - ModelOutputProtocolError, - ProtocolErrorReason, - RuntimeFinalAnswer, -) +from nexent.core.agents.cores.smolagents.output_protocol import ModelOutputProtocolError, ProtocolErrorReason, RuntimeFinalAnswer def _make_step_agent(rule, messages, model_output="ok"): @@ -552,7 +545,7 @@ def test_step_stream_checkpoint3_tool_input_blocked_error_isinstance_branch(): def test_step_stream_generic_exec_error_raises_agent_execution_error(): """Checkpoint ③: a non-block exec error (no pending_refusal, not ToolInputBlockedError) falls through the refusal guard and raises AgentExecutionError.""" - from nexent.core.agents.core_agent import AgentExecutionError + from nexent.core.agents.cores.smolagents.core_agent import AgentExecutionError rule = GuardrailRule(name="pii", pattern="机密信息", severity="block") agent = _make_step_agent( rule, messages=[_msg("user", "hello")], model_output="print(1)", @@ -585,7 +578,7 @@ def test_step_stream_wrapped_mcp_timeout_bypasses_agent_retry(): def test_step_stream_generic_timeout_remains_agent_execution_error(): """A non-MCP executor timeout must not be reported as an MCP timeout.""" - from nexent.core.agents.core_agent import AgentExecutionError + from nexent.core.agents.cores.smolagents.core_agent import AgentExecutionError rule = GuardrailRule(name="pii", pattern="機密信息", severity="block") agent = _make_step_agent( @@ -621,7 +614,7 @@ def test_step_stream_native_mcp_timeout_preserves_error_identity(): def test_step_stream_precheck_action_scope_blocking_raises_before_exec(): """Checkpoint precheck: code_action with a dangerous term (os.system) fails the action_scope check (blocking) → AgentExecutionError raised before the tool runs.""" - from nexent.core.agents.core_agent import AgentExecutionError + from nexent.core.agents.cores.smolagents.core_agent import AgentExecutionError rule = GuardrailRule(name="pii", pattern="机密信息", severity="block") agent = _make_step_agent( rule, @@ -708,7 +701,7 @@ def test_guardrail_wrap_tools_handles_non_dict_container(): def test_append_verification_feedback_sets_observations_when_empty(): """_append_verification_feedback: when action_step.observations is empty, it is set (not appended) to the feedback string (the else branch).""" - from nexent.core.agents.verification import VerificationResult + from nexent.core.agents.cores.smolagents.verification import VerificationResult rule = GuardrailRule(name="pii", pattern="机密信息", severity="block") agent = _make_step_agent(rule, messages=[_msg("user", "hello")]) result = MagicMock(spec=VerificationResult) diff --git a/test/sdk/core/agents/test_guardrail_engine.py b/test/sdk/core/agents/test_guardrail_engine.py index 280b378ac4..0303732458 100644 --- a/test/sdk/core/agents/test_guardrail_engine.py +++ b/test/sdk/core/agents/test_guardrail_engine.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Unit tests for the guardrail engine, severity resolver, and refusal rendering. Complements ``test_guardrail_checkpoints.py`` (which drives the real ``_guardrail_wrap_one`` @@ -14,17 +15,8 @@ import pytest -from nexent.core.agents.agent_model import GuardrailConfig, GuardrailRule -from nexent.core.agents.verification import ( - GuardrailDecision, - GuardrailEngine, - SeverityResolver, - VerificationResult, - _guardrail_locale, - latest_user_message_text, - render_guardrail_refusal, - render_tool_input_refusal, -) +from nexent.core.agents.contracts.config import GuardrailConfig, GuardrailRule +from nexent.core.agents.cores.smolagents.verification import GuardrailDecision, GuardrailEngine, SeverityResolver, VerificationResult, _guardrail_locale, latest_user_message_text, render_guardrail_refusal, render_tool_input_refusal KEYWORD = "机密信息" PATTERN = KEYWORD diff --git a/test/sdk/core/agents/test_history_context_runtime.py b/test/sdk/core/agents/test_history_context_runtime.py index ea0eeb8ca2..5b59a836ac 100644 --- a/test/sdk/core/agents/test_history_context_runtime.py +++ b/test/sdk/core/agents/test_history_context_runtime.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import json import logging @@ -9,7 +10,7 @@ from nexent.core.agents.context import ContextItemInput, ContextManager, ContextManagerConfig from nexent.core.agents.context.evidence import ContextEvidenceCollector -from nexent.core.context_runtime.contracts import ContextEvidence +from nexent.core.agents.contracts.context import ContextEvidence from nexent.core.agents.context.history_compression import ( HistorySummaryInput, _validate_summary_contract, diff --git a/test/sdk/core/agents/test_jiuwen_answer_boundary.py b/test/sdk/core/agents/test_jiuwen_answer_boundary.py index c4a940e5a1..2e6a17437c 100644 --- a/test/sdk/core/agents/test_jiuwen_answer_boundary.py +++ b/test/sdk/core/agents/test_jiuwen_answer_boundary.py @@ -5,12 +5,8 @@ from types import SimpleNamespace import pytest -from nexent.core.agent_runtime_support.answer import ( - AnswerBoundaryError, - PublicTextFilter, - normalize_public_answer, -) -from nexent.core.agents.core_adapters.events import JiuwenEventAdapter +from nexent.core.agents.output.answer import AnswerBoundaryError, PublicTextFilter, normalize_public_answer +from nexent.core.agents.cores.jiuwen.events import JiuwenEventAdapter from nexent.core.model_service.contracts import AssistantMessage from nexent.core.utils.observer import MessageObserver diff --git a/test/sdk/core/agents/test_jiuwen_capacity_output.py b/test/sdk/core/agents/test_jiuwen_capacity_output.py index f884f658e5..2d0d20d365 100644 --- a/test/sdk/core/agents/test_jiuwen_capacity_output.py +++ b/test/sdk/core/agents/test_jiuwen_capacity_output.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Shared CM capacity must survive Jiuwen observations without changing execution policy.""" import json @@ -6,12 +7,13 @@ from unittest.mock import Mock import pytest -from nexent.core.agent_runtime_support.output import StepObservation -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo +from nexent.core.agents.contracts.output import StepObservation +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.agents.context import ContextManagerConfig -from nexent.core.agents.context.runtime import ManagedContextRuntime -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.core_adapters.jiuwen_context import JiuwenContextBridge +from nexent.core.agents.cores.smolagents.context import ManagedContextRuntime +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.cores.jiuwen.context import JiuwenContextBridge from nexent.core.concurrency import RunCancellationScope from nexent.core.model_service.contracts import AssistantMessage, ModelError, TokenUsage from nexent.core.utils.observer import MessageObserver diff --git a/test/sdk/core/agents/test_jiuwen_context.py b/test/sdk/core/agents/test_jiuwen_context.py index 54ecf0f9bf..4f2438822b 100644 --- a/test/sdk/core/agents/test_jiuwen_context.py +++ b/test/sdk/core/agents/test_jiuwen_context.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Shared CM consumption and native tool association regression contracts.""" from threading import Event @@ -5,11 +6,12 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.agents.context import ContextItem, ContextItemInput, ContextManagerConfig from nexent.core.agents.context.history_compression import HistorySummaryInput from nexent.core.agents.context.rendering import ContextItemRenderer -from nexent.core.agents.core_adapters.jiuwen_context import JiuwenContextBridge, SummaryModel +from nexent.core.agents.cores.jiuwen.context import JiuwenContextBridge, SummaryModel from nexent.core.concurrency import RunCancellationScope from nexent.core.concurrency.cancellation import RunTerminated from nexent.core.utils.observer import MessageObserver diff --git a/test/sdk/core/agents/test_jiuwen_history.py b/test/sdk/core/agents/test_jiuwen_history.py index c8af070e85..54bad0cc27 100644 --- a/test/sdk/core/agents/test_jiuwen_history.py +++ b/test/sdk/core/agents/test_jiuwen_history.py @@ -10,16 +10,9 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import ( - AgentConfig, - AgentHistory, - AgentRunInfo, - ModelConfig, -) -from nexent.core.agents.core_adapters.jiuwen import ( - JiuwenAdapter, - _prepend_context_messages, -) +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter, _prepend_context_messages from nexent.core.concurrency.cancellation import RunCancellationScope from nexent.core.model_service.contracts import AssistantMessage as ServiceMessage from nexent.core.model_service.contracts import ModelEvent @@ -134,14 +127,14 @@ def test_history_preserves_native_tool_calls_and_structured_content(dictionary_i def test_native_prompt_and_retry_configuration(monkeypatch, saved_profile): - from nexent.core.agents.core_adapters import jiuwen + from nexent.core.agents.cores.jiuwen import adapter as jiuwen adapter = adapter_with_history([AgentHistory(role="user", content="Analyze an old image")], saved_profile) adapter.run_info.thread_manager = SimpleNamespace() monkeypatch.setattr(adapter, "_skill_rails", list) bind_executor = Mock() monkeypatch.setattr(adapter, "_bind_text_executor", bind_executor) - monkeypatch.setattr(jiuwen, "NexentAgent", lambda **kwargs: SimpleNamespace( + monkeypatch.setattr(jiuwen, "AgentResources", lambda **kwargs: SimpleNamespace( _prepare_file_workspace=lambda query: query, )) factory = Mock() diff --git a/test/sdk/core/agents/test_jiuwen_mcp_names.py b/test/sdk/core/agents/test_jiuwen_mcp_names.py index 51f794c043..5add0f151d 100644 --- a/test/sdk/core/agents/test_jiuwen_mcp_names.py +++ b/test/sdk/core/agents/test_jiuwen_mcp_names.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, Mock import pytest -from nexent.core.agents.agent_model import ToolConfig -from nexent.core.agents.core_adapters import mcp +from nexent.core.agents.contracts.config import ToolConfig +from nexent.core.agents.cores.jiuwen import mcp from nexent.core.concurrency import RunCancellationScope from openjiuwen.core.foundation.tool.mcp.base import McpToolCard diff --git a/test/sdk/core/agents/test_jiuwen_mcp_transport.py b/test/sdk/core/agents/test_jiuwen_mcp_transport.py index 2053ac638c..8f0676d22e 100644 --- a/test/sdk/core/agents/test_jiuwen_mcp_transport.py +++ b/test/sdk/core/agents/test_jiuwen_mcp_transport.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Unit regressions for native MCP transport configuration.""" from unittest.mock import AsyncMock, MagicMock @@ -5,7 +6,7 @@ import pytest from openjiuwen.core.foundation.tool.mcp.base import McpServerConfig -from nexent.core.agents.core_adapters.mcp import _ConfiguredNativeClient +from nexent.core.agents.cores.jiuwen.mcp import _ConfiguredNativeClient @pytest.mark.asyncio diff --git a/test/sdk/core/agents/test_jiuwen_model_service.py b/test/sdk/core/agents/test_jiuwen_model_service.py index 65e35c67cd..dd6a56aca5 100644 --- a/test/sdk/core/agents/test_jiuwen_model_service.py +++ b/test/sdk/core/agents/test_jiuwen_model_service.py @@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, Mock import pytest -from nexent.core.agents.core_adapters.jiuwen_messages import resource_view -from nexent.core.agents.core_adapters.jiuwen_model import JiuwenServiceModel -from nexent.core.agents.core_adapters.jiuwen_protocol import JiuwenProtocolError +from nexent.core.agents.cores.jiuwen.messages import resource_view +from nexent.core.agents.cores.jiuwen.model import JiuwenServiceModel +from nexent.core.agents.cores.jiuwen.protocol import JiuwenProtocolError from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated from nexent.core.model_service.contracts import ( AssistantMessage, @@ -276,9 +276,10 @@ def events(request): def test_adapter_requires_trusted_matching_snapshot(saved_profile, variant): from threading import Event - from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig - from nexent.core.agents.core_adapters.base import UnsupportedAgentCoreConfig - from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.agents.contracts.config import AgentConfig, ModelConfig + from nexent.core.agents.contracts.run import AgentRunInfo + from nexent.core.agents.contracts.adapter import UnsupportedAgentCoreConfig + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter data = saved_profile.model_dump() if variant == "unsaved": data["outlet_id"] = data["connection"]["credential_ref"]["model_id"] = None @@ -294,8 +295,8 @@ def test_adapter_requires_trusted_matching_snapshot(saved_profile, variant): def test_request_output_budget_cannot_be_silently_ignored(saved_profile): - from nexent.core.agents.core_adapters.base import UnsupportedAgentCoreConfig - from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.agents.contracts.adapter import UnsupportedAgentCoreConfig + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter cap = saved_profile.parameter_values.get("max_output_tokens", saved_profile.parameter_values.get("max_tokens")) info = SimpleNamespace(agent_config=SimpleNamespace(requested_output_tokens=None), @@ -308,7 +309,7 @@ def test_request_output_budget_cannot_be_silently_ignored(saved_profile): @pytest.mark.parametrize("invalid", [None, "tools", "refusal", "nontext"]) def test_context_summary_accepts_shared_chat_messages_and_validates_completion(saved_profile, invalid): - from nexent.core.agents.core_adapters.jiuwen_context import SummaryModel + from nexent.core.agents.cores.jiuwen.context import SummaryModel from smolagents.models import ChatMessage, MessageRole service = Mock() diff --git a/test/sdk/core/agents/test_jiuwen_native_files.py b/test/sdk/core/agents/test_jiuwen_native_files.py index 405438db4e..84c3dd9bc1 100644 --- a/test/sdk/core/agents/test_jiuwen_native_files.py +++ b/test/sdk/core/agents/test_jiuwen_native_files.py @@ -7,19 +7,12 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.agent_model import ( - AgentConfig, - AgentRunInfo, - ModelConfig, - ToolConfig, -) -from nexent.core.agents.core_adapters.base import UnsupportedAgentCoreConfig -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.core_adapters.jiuwen_messages import resource_view -from nexent.core.agents.core_adapters.jiuwen_protocol import ( - EXECUTION_TOOL, - NATIVE_EXECUTION_TOOL, -) +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentRunInfo +from nexent.core.agents.contracts.adapter import UnsupportedAgentCoreConfig +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.cores.jiuwen.messages import resource_view +from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, NATIVE_EXECUTION_TOOL from nexent.core.utils.observer import MessageObserver from test_jiuwen_model_service import saved_profile as _saved_profile @@ -30,8 +23,8 @@ def test_executor_registration_reuses_sandbox_and_keeps_self_out_of_bindings(mocker, text_mode): executor = Mock() executor_result = SimpleNamespace(output="created.docx", logs="written", is_final_answer=False) - execute = mocker.patch("nexent.core.agents.sandbox._execute_with_tool_context", return_value=executor_result) - build = mocker.patch("nexent.core.agents.sandbox.build_python_executor") + execute = mocker.patch("nexent.core.agents.sandbox.execution._execute_with_tool_context", return_value=executor_result) + build = mocker.patch("nexent.core.agents.sandbox.execution.build_python_executor") adapter = JiuwenAdapter(SimpleNamespace(agent_config=SimpleNamespace(name="files"))) adapter.text_protocol = text_mode adapter.resources = SimpleNamespace(agent=SimpleNamespace(python_executor=executor)) @@ -59,8 +52,8 @@ def test_native_executor_is_model_visible_but_text_internal_entry_is_never_adver def test_native_guidance_uses_authorized_skill_api_not_guessed_s3_paths(mocker): - mocker.patch("nexent.core.agents.run_agent._get_authorized_context_items", return_value=[]) - mocker.patch("nexent.core.agents.run_agent._get_authorized_history", return_value=[]) + mocker.patch("nexent.core.agents.orchestration.execution._get_authorized_context_items", return_value=[]) + mocker.patch("nexent.core.agents.orchestration.execution._get_authorized_history", return_value=[]) info = SimpleNamespace(agent_config=SimpleNamespace(name="files", instructions="Write files", description="")) adapter = JiuwenAdapter(info) prompt = adapter._compile_context() diff --git a/test/sdk/core/agents/test_jiuwen_output.py b/test/sdk/core/agents/test_jiuwen_output.py index 1cbd336ef6..8e5b7edb9a 100644 --- a/test/sdk/core/agents/test_jiuwen_output.py +++ b/test/sdk/core/agents/test_jiuwen_output.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Public output ordering, explicit finality, call correlation and error contracts.""" import json @@ -5,8 +6,8 @@ import pytest from pydantic import ValidationError -from nexent.core.agent_runtime_support.output import RunResult, StepObservation -from nexent.core.agents.core_adapters.events import JiuwenEventAdapter +from nexent.core.agents.contracts.output import RunResult, StepObservation +from nexent.core.agents.cores.jiuwen.events import JiuwenEventAdapter from nexent.core.model_service.contracts import AssistantMessage, TokenUsage, ToolCall from nexent.core.utils.observer import MessageObserver @@ -96,8 +97,9 @@ def test_native_llm_output_does_not_duplicate_canonical_model_events(): '{"status":"error","message":"Skill error"}']) def test_explicit_resource_failure_status_survives_later_text(result): from threading import Event - from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo - from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter + from nexent.core.agents.contracts.config import AgentConfig + from nexent.core.agents.contracts.run import AgentRunInfo + from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter observer=MessageObserver() info=AgentRunInfo(query="Current",observer=observer,stop_event=Event(),model_config_list=[], @@ -116,13 +118,14 @@ def test_explicit_resource_failure_status_survives_later_text(result): def test_reused_run_info_does_not_keep_old_core_terminal(mocker): from threading import Event - from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo - from nexent.core.agents.run_agent import _agent_run_thread + from nexent.core.agents.contracts.config import AgentConfig + from nexent.core.agents.contracts.run import AgentRunInfo + from nexent.core.agents.orchestration.entry import _agent_run_thread info=AgentRunInfo(query="New task",observer=MessageObserver(),stop_event=Event(),model_config_list=[], agent_config=AgentConfig(name="agent",description="Fixture",model_name="fixture",tools=[])) info.run_result=RunResult(turn_id="old",status="completed",end_reason="runner_answer",final_content="Old") info.stop_event.set() - mocker.patch("nexent.core.agents.core_adapters.AgentCoreFactory.create") + mocker.patch("nexent.core.agents.factory.AgentCoreFactory.create") _agent_run_thread(info) assert info.run_result is None and info.attempt_outcome=="stopped" diff --git a/test/sdk/core/agents/test_jiuwen_protocol.py b/test/sdk/core/agents/test_jiuwen_protocol.py index 227a71c5d0..8467f8d793 100644 --- a/test/sdk/core/agents/test_jiuwen_protocol.py +++ b/test/sdk/core/agents/test_jiuwen_protocol.py @@ -1,10 +1,8 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Unit contracts for the controlled text execution grammar.""" import pytest -from nexent.core.agents.core_adapters.jiuwen_protocol import ( - JiuwenProtocolError, - parse_execution_blocks, -) +from nexent.core.agents.cores.jiuwen.protocol import JiuwenProtocolError, parse_execution_blocks @pytest.mark.parametrize("text,code", [ diff --git a/test/sdk/core/agents/test_jiuwen_resources.py b/test/sdk/core/agents/test_jiuwen_resources.py index 48c08e45ae..9b2a010977 100644 --- a/test/sdk/core/agents/test_jiuwen_resources.py +++ b/test/sdk/core/agents/test_jiuwen_resources.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Resource bridge validation, loop ownership and captured MCP parameters.""" import asyncio @@ -8,12 +9,9 @@ import pytest from jsonschema import ValidationError -from nexent.core.agents.core_adapters.jiuwen import JiuwenAdapter -from nexent.core.agents.core_adapters.jiuwen_resources import JiuwenResourceBridge -from nexent.core.agents.core_adapters.mcp import ( - JiuwenMCPCollection, - _ManagedNativeClient, -) +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.cores.jiuwen.resources import JiuwenResourceBridge +from nexent.core.agents.cores.jiuwen.mcp import JiuwenMCPCollection, _ManagedNativeClient from nexent.core.concurrency import ( LanePolicy, ManagedTaskSpec, @@ -105,7 +103,7 @@ async def forward(value): @pytest.mark.asyncio @pytest.mark.parametrize("authorized", [True, False, "empty"]) async def test_skill_root_and_name_must_both_belong_to_the_same_authorized_snapshot(tmp_path, authorized): - from nexent.core.agents.core_adapters.skills import NexentSkillRail + from nexent.core.agents.cores.jiuwen.skills import NexentSkillRail first, second = tmp_path / "first", tmp_path / "second" path = first / ("allowed" if authorized else "second_only") / "SKILL.md" diff --git a/test/sdk/core/agents/test_lowercase_output_protocol_isolated.py b/test/sdk/core/agents/test_lowercase_output_protocol_isolated.py index fbba03ca6c..bf34cea312 100644 --- a/test/sdk/core/agents/test_lowercase_output_protocol_isolated.py +++ b/test/sdk/core/agents/test_lowercase_output_protocol_isolated.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Exercise the output protocol without importing unrelated Nexent services.""" import importlib.util @@ -10,17 +11,20 @@ @pytest.fixture def protocol(monkeypatch): - for name in ("nexent", "nexent.core", "nexent.core.agents"): + root = Path(__file__).resolve().parents[4] + for name in ("nexent", "nexent.core", "nexent.core.agents", "nexent.core.agents.contracts", "nexent.core.agents.prompt"): module = types.ModuleType(name) - module.__path__ = [] + module.__path__ = [str(root / "sdk" / name.replace(".", "/"))] monkeypatch.setitem(sys.modules, name, module) - clarification = types.ModuleType("nexent.core.agents.clarification") + clarification = types.ModuleType("nexent.core.agents.contracts.clarification") clarification.CLARIFICATION_SCHEMA_GUIDANCE = "" clarification.ClarificationForm = type("ClarificationForm", (), {}) monkeypatch.setitem(sys.modules, clarification.__name__, clarification) - root = Path(__file__).resolve().parents[4] + guidance = types.ModuleType("nexent.core.agents.prompt.clarification") + guidance.CLARIFICATION_SCHEMA_GUIDANCE = "" + monkeypatch.setitem(sys.modules, guidance.__name__, guidance) spec = importlib.util.spec_from_file_location( - "nexent.core.agents.output_protocol", root / "sdk/nexent/core/agents/output_protocol.py", + "nexent.core.agents.cores.smolagents.output_protocol", root / "sdk/nexent/core/agents/cores/smolagents/output_protocol.py", ) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) diff --git a/test/sdk/core/agents/test_managed_mcp.py b/test/sdk/core/agents/test_managed_mcp.py index fe65143020..ca8e2ab506 100644 --- a/test/sdk/core/agents/test_managed_mcp.py +++ b/test/sdk/core/agents/test_managed_mcp.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import asyncio import threading from concurrent.futures import CancelledError @@ -7,7 +8,7 @@ import mcp.types import mcpadapt.core import pytest -from nexent.core.agents.managed_mcp import ManagedMCPToolCollection +from nexent.core.agents.resources.mcp import ManagedMCPToolCollection from nexent.consts.mcp_errors import MCPConnectionTimeoutError, MCPToolTimeoutError from nexent.core.concurrency import LanePolicy, RunCancellationScope, ThreadManager diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py index b8e3c299ae..f962506170 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -147,22 +147,26 @@ class _MockAgentRunMetadata: mock_nexent_core_utils_observer_module.ProcessType = _MockProcessType mock_sdk_module = types.ModuleType("sdk") -mock_sdk_nexent_module = types.ModuleType("sdk.nexent") -mock_sdk_nexent_core_module = types.ModuleType("sdk.nexent.core") -mock_sdk_nexent_core_agents_module = types.ModuleType("sdk.nexent.core.agents") -mock_sdk_nexent_core_tools_module = types.ModuleType("sdk.nexent.core.tools") -mock_sdk_nexent_core_utils_module = types.ModuleType("sdk.nexent.core.utils") +mock_sdk_nexent_module = types.ModuleType("nexent") +mock_sdk_nexent_core_module = types.ModuleType("nexent.core") +mock_sdk_nexent_core_agents_module = types.ModuleType("nexent.core.agents") +mock_sdk_nexent_core_tools_module = types.ModuleType("nexent.core.tools") +mock_sdk_nexent_core_utils_module = types.ModuleType("nexent.core.utils") mock_sdk_nexent_core_utils_observer_module = types.ModuleType( - "sdk.nexent.core.utils.observer" + "nexent.core.utils.observer" ) mock_sdk_nexent_core_utils_observer_module.MessageObserver = _MockMessageObserver mock_sdk_nexent_core_utils_observer_module.ProcessType = _MockProcessType -mock_sdk_nexent_monitor_module = types.ModuleType("sdk.nexent.monitor") +mock_sdk_nexent_monitor_module = types.ModuleType("nexent.monitor") mock_sdk_nexent_monitor_module.__path__ = [] mock_sdk_nexent_monitor_module.AgentRunMetadata = _MockAgentRunMetadata mock_sdk_nexent_monitor_module.get_agent_monitoring_context = MagicMock(return_value=None) mock_sdk_nexent_monitor_module.get_monitoring_manager = MagicMock() -mock_sdk_nexent_monitor_monitoring_module = types.ModuleType("sdk.nexent.monitor.monitoring") +from contextlib import nullcontext +mock_sdk_nexent_monitor_module.OPENINFERENCE_SPAN_KIND_AGENT = "AGENT" +mock_sdk_nexent_monitor_module.agent_monitoring_context = lambda *args, **kwargs: nullcontext() + +mock_sdk_nexent_monitor_monitoring_module = types.ModuleType("nexent.monitor.monitoring") mock_sdk_nexent_monitor_monitoring_module.record_model_call = MagicMock() @@ -185,19 +189,19 @@ def __init__(self, enabled=False, **kwargs): setattr(self, key, value) -mock_sdk_context_runtime_module = types.ModuleType("sdk.nexent.core.context_runtime") +mock_sdk_context_runtime_module = types.ModuleType("nexent.core.agents.cores.smolagents.context_contracts") mock_sdk_context_runtime_module.__path__ = [] -mock_sdk_context_runtime_managed_module = types.ModuleType("sdk.nexent.core.context_runtime.managed") +mock_sdk_context_runtime_managed_module = types.ModuleType("nexent.core.agents.cores.smolagents.context") mock_sdk_context_runtime_managed_module.__path__ = [] mock_sdk_context_runtime_managed_runtime_module = types.ModuleType( - "sdk.nexent.core.context_runtime.managed.runtime" + "nexent.core.agents.cores.smolagents.context" ) mock_sdk_context_runtime_managed_runtime_module.ManagedContextRuntime = _MockManagedContextRuntime -mock_sdk_agent_context_module = types.ModuleType("sdk.nexent.core.agents.agent_context") +mock_sdk_agent_context_module = types.ModuleType("nexent.core.agents.context") mock_sdk_agent_context_module.ContextManager = _MockContextManager -mock_sdk_summary_config_module = types.ModuleType("sdk.nexent.core.agents.summary_config") +mock_sdk_summary_config_module = types.ModuleType("nexent.core.agents.context.config") mock_sdk_summary_config_module.ContextManagerConfig = _MockContextManagerConfig -mock_sdk_agent_context_domain_module = types.ModuleType("sdk.nexent.core.agents.context") +mock_sdk_agent_context_domain_module = types.ModuleType("nexent.core.agents.context") mock_sdk_agent_context_domain_module.__path__ = [ str(SDK_SOURCE_ROOT / "nexent" / "core" / "agents" / "context") ] @@ -302,7 +306,7 @@ class _MockToolSign: "openai.types.chat": MagicMock(), "openai.types.chat.chat_completion_message": MagicMock(ChatCompletionMessage=mock_openai_chat_completion_message), "openai.types.chat.chat_completion_message_param": MagicMock(), - # Mock exa_py to avoid importing the real package when sdk.nexent.core.tools imports it + # Mock exa_py to avoid importing the real package when nexent.core.tools imports it "exa_py": MagicMock(Exa=MagicMock()), # Mock paramiko to avoid PyO3 import issues in tests "paramiko": MagicMock(), @@ -319,20 +323,21 @@ class _MockToolSign: "nexent.utils": mock_nexent_utils_module, "nexent.core.utils.observer": mock_nexent_core_utils_observer_module, "sdk": mock_sdk_module, - "sdk.nexent": mock_sdk_nexent_module, - "sdk.nexent.core": mock_sdk_nexent_core_module, - "sdk.nexent.core.agents": mock_sdk_nexent_core_agents_module, - "sdk.nexent.core.tools": mock_sdk_nexent_core_tools_module, - "sdk.nexent.core.context_runtime": mock_sdk_context_runtime_module, - "sdk.nexent.core.context_runtime.managed": mock_sdk_context_runtime_managed_module, - "sdk.nexent.core.context_runtime.managed.runtime": mock_sdk_context_runtime_managed_runtime_module, - "sdk.nexent.core.agents.agent_context": mock_sdk_agent_context_module, - "sdk.nexent.core.agents.context": mock_sdk_agent_context_domain_module, - "sdk.nexent.core.agents.summary_config": mock_sdk_summary_config_module, - "sdk.nexent.core.utils": mock_sdk_nexent_core_utils_module, - "sdk.nexent.core.utils.observer": mock_sdk_nexent_core_utils_observer_module, - "sdk.nexent.monitor": mock_sdk_nexent_monitor_module, - "sdk.nexent.monitor.monitoring": mock_sdk_nexent_monitor_monitoring_module, + "nexent": mock_sdk_nexent_module, + "nexent.core": mock_sdk_nexent_core_module, + "nexent.core.agents": mock_sdk_nexent_core_agents_module, + "nexent.core.tools": mock_sdk_nexent_core_tools_module, + "nexent.core.agents.cores.smolagents.context_contracts": mock_sdk_context_runtime_module, + "nexent.core.agents.cores.smolagents.context": mock_sdk_context_runtime_managed_module, + "nexent.core.agents.cores.smolagents.context": mock_sdk_context_runtime_managed_runtime_module, + "nexent.core.agents.context": mock_sdk_agent_context_module, + "nexent.core.agents.cores.smolagents.context": mock_sdk_context_runtime_managed_runtime_module, + "nexent.core.agents.context": mock_sdk_agent_context_domain_module, + "nexent.core.agents.context.config": mock_sdk_summary_config_module, + "nexent.core.utils": mock_sdk_nexent_core_utils_module, + "nexent.core.utils.observer": mock_sdk_nexent_core_utils_observer_module, + "nexent.monitor": mock_sdk_nexent_monitor_module, + "nexent.monitor.monitoring": mock_sdk_nexent_monitor_monitoring_module, "nexent.core.management.services.agent.prompt": mock_prompt_template_utils_module, "nexent.core.utils.tools_common_message": mock_tools_common_message_module, "nexent.core.models": mock_nexent_core_models_module, @@ -348,9 +353,9 @@ class _MockToolSign: # Mock linkup to avoid import issues "linkup": MagicMock(), # Mock the OpenAIModel import - "sdk.nexent.core.models.openai_llm": MagicMock(OpenAIModel=mock_openai_model_class), + "nexent.core.models.openai_llm": MagicMock(OpenAIModel=mock_openai_model_class), # Mock CoreAgent import - "sdk.nexent.core.agents.core_agent": MagicMock( + "nexent.core.agents.cores.smolagents.core_agent": MagicMock( CoreAgent=mock_core_agent_class, convert_code_format=lambda s: s if isinstance(s, str) else str(s), ), @@ -359,6 +364,17 @@ class _MockToolSign: # --------------------------------------------------------------------------- # Import the classes under test with patched dependencies in place # --------------------------------------------------------------------------- +# Apply existing dependency doubles at canonical lookup sites after module migration. +for _legacy_name, _double in list(module_mocks.items()): + if _legacy_name.startswith("nexent"): + _canonical_name = _legacy_name.removeprefix("sdk.") + if _canonical_name == "nexent.core.agents.cores.smolagents.core_agent": + _canonical_name = "nexent.core.agents.cores.smolagents.core_agent" + module_mocks[_canonical_name] = _double +for _package in ("nexent", "nexent.core", "nexent.core.agents", "nexent.core.models"): + _package_double = module_mocks.setdefault(_package, types.ModuleType(_package)) + _package_double.__path__ = [str(Path(__file__).resolve().parents[4] / "sdk" / _package.replace(".", "/"))] + with patch.dict("sys.modules", module_mocks): # Create mock http_client_manager module for analyze_text_file_tool # This is needed because analyze_text_file_tool.py uses absolute import: @@ -369,21 +385,32 @@ class _MockToolSign: # We need to add this to sys.modules before the import happens sys.modules["nexent.utils.http_client_manager"] = mock_http_client_manager_module - from sdk.nexent.core.agents import nexent_agent - from sdk.nexent.core.agents.nexent_agent import ( - NexentAgent, ActionStep, TaskStep, _has_host_tools, _is_retriever_tool, - _build_tool_input, _wrap_tool_with_monitoring, _tool_name, - SAFE_PYTHON_INTERPRETER_IMPORTS, get_local_python_authorized_imports, - ) - from sdk.nexent.consts.mcp_errors import MCPToolTimeoutError - from sdk.nexent.core.agents.agent_model import ToolConfig, ModelConfig, AgentConfig, AgentHistory, ExternalA2AAgentConfig + from nexent.core.agents.cores.smolagents import agent as nexent_agent + from nexent.core.agents.cores.smolagents.agent import NexentAgent, ActionStep, TaskStep, _has_host_tools, _is_retriever_tool, _build_tool_input, _wrap_tool_with_monitoring, _tool_name, SAFE_PYTHON_INTERPRETER_IMPORTS, get_local_python_authorized_imports + from nexent.consts.mcp_errors import MCPToolTimeoutError + from nexent.core.agents.contracts.config import ToolConfig, ModelConfig, AgentConfig, ExternalA2AAgentConfig + from nexent.core.agents.contracts.run import AgentHistory + + _agent_module_cache = {key: value for key, value in sys.modules.items() + if key.startswith("nexent.core.agents")} # Clean up after import sys.modules.pop("nexent.utils.http_client_manager", None) # Retain the tested module after patch.dict restores the module registry so it # can be reloaded by tests that exercise conditional imports. -sys.modules["sdk.nexent.core.agents.nexent_agent"] = nexent_agent +sys.modules["nexent.core.agents.cores.smolagents.agent"] = nexent_agent +sys.modules[nexent_agent.__name__] = nexent_agent +sys.modules.update(_agent_module_cache) +for _key, _double in module_mocks.items(): + if _key.startswith("nexent"): + _canonical = _key.removeprefix("sdk.") + if _canonical == "nexent.core.agents.cores.smolagents.core_agent": + _canonical = "nexent.core.agents.cores.smolagents.core_agent" + sys.modules.setdefault(_canonical, _double) +from nexent.core.agents.resources import workspace as resource_workspace +from nexent.core.agents.resources import monitoring as resource_monitoring + # Keep the lightweight runtime modules available for create_single_agent() @@ -391,19 +418,19 @@ class _MockToolSign: # context has restored sys.modules, while nexent_agent now performs runtime # imports inside create_single_agent(). sys.modules.setdefault("sdk", mock_sdk_module) -sys.modules.setdefault("sdk.nexent", mock_sdk_nexent_module) -sys.modules.setdefault("sdk.nexent.core", mock_sdk_nexent_core_module) -sys.modules.setdefault("sdk.nexent.core.agents", mock_sdk_nexent_core_agents_module) -sys.modules.setdefault("sdk.nexent.core.tools", mock_sdk_nexent_core_tools_module) -sys.modules.setdefault("sdk.nexent.core.context_runtime", mock_sdk_context_runtime_module) -sys.modules.setdefault("sdk.nexent.core.context_runtime.managed", mock_sdk_context_runtime_managed_module) +sys.modules.setdefault("nexent", mock_sdk_nexent_module) +sys.modules.setdefault("nexent.core", mock_sdk_nexent_core_module) +sys.modules.setdefault("nexent.core.agents", mock_sdk_nexent_core_agents_module) +sys.modules.setdefault("nexent.core.tools", mock_sdk_nexent_core_tools_module) +sys.modules.setdefault("nexent.core.agents.cores.smolagents.context_contracts", mock_sdk_context_runtime_module) +sys.modules.setdefault("nexent.core.agents.cores.smolagents.context", mock_sdk_context_runtime_managed_module) sys.modules.setdefault( - "sdk.nexent.core.context_runtime.managed.runtime", + "nexent.core.agents.cores.smolagents.context", mock_sdk_context_runtime_managed_runtime_module, ) -sys.modules.setdefault("sdk.nexent.core.agents.agent_context", mock_sdk_agent_context_module) -sys.modules.setdefault("sdk.nexent.core.agents.summary_config", mock_sdk_summary_config_module) -sys.modules.setdefault("sdk.nexent.core.agents.context", mock_sdk_agent_context_domain_module) +sys.modules.setdefault("nexent.core.agents.context", mock_sdk_agent_context_module) +sys.modules.setdefault("nexent.core.agents.context.config", mock_sdk_summary_config_module) +sys.modules.setdefault("nexent.core.agents.context", mock_sdk_agent_context_domain_module) # ---------------------------------------------------------------------------- @@ -421,7 +448,7 @@ def reset_mocks(): def patch_convert_code_format(): """Ensure convert_code_format returns a plain string for downstream re.sub.""" import sys - module = sys.modules.get("sdk.nexent.core.agents.nexent_agent") + module = sys.modules.get("nexent.core.agents.cores.smolagents.agent") if module is None: # If the module is not imported yet, skip patching to avoid triggering imports yield @@ -535,15 +562,15 @@ def test_get_local_python_authorized_imports_matches_executor_defaults(monkeypat def test_type_checking_imports_resolve_context_and_subagent_types(monkeypatch): """Verify type-only imports resolve their expected public symbols.""" - context_module = types.ModuleType("sdk.nexent.core.agents.context") + context_module = types.ModuleType("nexent.core.agents.context") context_item_input = type("ContextItemInput", (), {}) context_module.ContextItemInput = context_item_input - subagent_module = types.ModuleType("sdk.nexent.core.agents.subagent_wrapper") + subagent_module = types.ModuleType("nexent.core.agents.resources.subagents") subagent_tool_wrapper = type("SubAgentToolWrapper", (), {}) subagent_module.SubAgentToolWrapper = subagent_tool_wrapper - agent_model_module = types.ModuleType("sdk.nexent.core.agents.agent_model") + agent_model_module = types.ModuleType("nexent.core.agents.contracts.config") agent_model_module.AgentConfig = AgentConfig agent_model_module.AgentHistory = AgentHistory agent_model_module.ModelConfig = ModelConfig @@ -554,9 +581,10 @@ def test_type_checking_imports_resolve_context_and_subagent_types(monkeypatch): sys.modules, { **module_mocks, - "sdk.nexent.core.agents.agent_model": agent_model_module, - "sdk.nexent.core.agents.context": context_module, - "sdk.nexent.core.agents.subagent_wrapper": subagent_module, + "nexent.core.agents.contracts.config": agent_model_module, + "nexent.core.agents.contracts.run": agent_model_module, + "nexent.core.agents.context": context_module, + "nexent.core.agents.resources.subagents": subagent_module, }, ): reloaded_module = importlib.reload(nexent_agent) @@ -3911,7 +3939,7 @@ def test_create_single_agent_with_external_a2a_agents(self, nexent_agent_instanc mock_a2a_module = MagicMock() mock_a2a_module.ExternalA2AAgentWrapper = mock_wrapper_class - with patch.dict("sys.modules", {"sdk.nexent.core.agents.a2a_agent_proxy": mock_a2a_module}): + with patch.dict("sys.modules", {"nexent.core.agents.resources.a2a": mock_a2a_module}): with patch.object(nexent_agent, 'CoreAgent', return_value=mock_core_agent) as mock_core_agent_fn: result = nexent_agent_instance.create_single_agent(agent_config) @@ -3967,7 +3995,7 @@ def test_create_single_agent_with_multiple_external_a2a_agents(self, nexent_agen mock_a2a_module = MagicMock() mock_a2a_module.ExternalA2AAgentWrapper = mock_wrapper_class - with patch.dict("sys.modules", {"sdk.nexent.core.agents.a2a_agent_proxy": mock_a2a_module}): + with patch.dict("sys.modules", {"nexent.core.agents.resources.a2a": mock_a2a_module}): with patch.object(nexent_agent, 'CoreAgent', return_value=mock_core_agent) as mock_core_agent_fn: result = nexent_agent_instance.create_single_agent(agent_config) @@ -4004,7 +4032,7 @@ def test_create_single_agent_with_external_a2a_agent_import_error(self, nexent_a mock_a2a_module = MagicMock() mock_a2a_module.ExternalA2AAgentWrapper = MagicMock(side_effect=ImportError("Module not found")) - with patch.dict("sys.modules", {"sdk.nexent.core.agents.a2a_agent_proxy": mock_a2a_module}): + with patch.dict("sys.modules", {"nexent.core.agents.resources.a2a": mock_a2a_module}): with pytest.raises(ValueError, match="Error in creating external A2A agent wrapper:"): nexent_agent_instance.create_single_agent(agent_config) @@ -4031,7 +4059,7 @@ def test_create_single_agent_with_external_a2a_agent_wrapper_error(self, nexent_ mock_a2a_module = MagicMock() mock_a2a_module.ExternalA2AAgentWrapper = MagicMock(side_effect=Exception("Wrapper creation failed")) - with patch.dict("sys.modules", {"sdk.nexent.core.agents.a2a_agent_proxy": mock_a2a_module}): + with patch.dict("sys.modules", {"nexent.core.agents.resources.a2a": mock_a2a_module}): with pytest.raises(ValueError, match="Error in creating external A2A agent wrapper:"): nexent_agent_instance.create_single_agent(agent_config) @@ -4070,7 +4098,7 @@ def test_create_single_agent_with_external_and_managed_agents(self, nexent_agent mock_a2a_module = MagicMock() mock_a2a_module.ExternalA2AAgentWrapper = mock_wrapper_class - with patch.dict("sys.modules", {"sdk.nexent.core.agents.a2a_agent_proxy": mock_a2a_module}): + with patch.dict("sys.modules", {"nexent.core.agents.resources.a2a": mock_a2a_module}): with patch.object(nexent_agent, 'CoreAgent', return_value=mock_core_agent) as mock_core_agent_fn: result = nexent_agent_instance.create_single_agent(agent_config) @@ -4296,7 +4324,7 @@ class SandboxLevel(str, Enum): ) with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }), patch.object(nexent_agent, "CoreAgent", return_value=mock_core_agent) as mock_core_agent_fn: nexent_agent_instance.create_single_agent(mock_agent_config, _managed_context=True) @@ -4868,8 +4896,8 @@ def test_non_shared_workspace_pushes_archive( archive_context = MagicMock() archive_context.__enter__.return_value = archive_writer - with patch.object(nexent_agent, "Path", return_value=workspace), patch.object( - nexent_agent.tarfile, "open", return_value=archive_context + with patch.object(resource_workspace, "Path", return_value=workspace), patch.object( + resource_workspace.tarfile, "open", return_value=archive_context ), patch.object(nexent_agent_instance, "_grant_sandbox_output_access") as grant: if put_archive_result: nexent_agent_instance._push_file_workspace_to_sandbox() @@ -4930,7 +4958,7 @@ def test_shared_workspace_skips_archive_copy(self, nexent_agent_instance, tmp_pa python_executor=MagicMock(container=container) ) - with patch.object(nexent_agent, "Path", return_value=workspace), patch.object( + with patch.object(resource_workspace, "Path", return_value=workspace), patch.object( nexent_agent_instance, "_grant_sandbox_output_access" ) as grant_access: nexent_agent_instance._push_file_workspace_to_sandbox() @@ -5006,8 +5034,8 @@ def test_pull_workspace_extracts_safe_archive(self, nexent_agent_instance): python_executor=MagicMock(container=container) ) - with patch.object(nexent_agent, "Path", return_value=workspace), patch.object( - nexent_agent.tarfile, "open", return_value=archive_context + with patch.object(resource_workspace, "Path", return_value=workspace), patch.object( + resource_workspace.tarfile, "open", return_value=archive_context ): nexent_agent_instance._pull_file_workspace_from_sandbox() @@ -5042,9 +5070,9 @@ def test_pull_workspace_rejects_unsafe_archive( python_executor=MagicMock(container=container) ) - with patch.object(nexent_agent, "Path", return_value=workspace), patch.object( - nexent_agent.tarfile, "open", return_value=archive_context - ), patch.object(nexent_agent.logger, "warning") as warning: + with patch.object(resource_workspace, "Path", return_value=workspace), patch.object( + resource_workspace.tarfile, "open", return_value=archive_context + ), patch.object(resource_workspace.logger, "warning") as warning: nexent_agent_instance._pull_file_workspace_from_sandbox() warning.assert_called_once() @@ -5056,7 +5084,7 @@ def test_finalize_workspace_handles_missing_upload_tool( nexent_agent_instance.agent = MagicMock(tools={}) with patch.object(nexent_agent_instance, "_pull_file_workspace_from_sandbox"), patch.object( - nexent_agent.logger, "error" + resource_workspace.logger, "error" ) as error: nexent_agent_instance._finalize_file_workspace() @@ -5170,7 +5198,7 @@ def test_cleanup_logs_sandbox_failures( python_executor=MagicMock(container=container) ) - with patch.object(nexent_agent.logger, "warning") as warning: + with patch.object(resource_workspace.logger, "warning") as warning: nexent_agent_instance._cleanup_file_workspace() warning.assert_called_once() @@ -5182,8 +5210,8 @@ def test_cleanup_logs_host_removal_failure(self, nexent_agent_instance, tmp_path nexent_agent_instance.workspace_run_id = "run" nexent_agent_instance.agent = MagicMock(python_executor=None) - with patch.object(nexent_agent.shutil, "rmtree", side_effect=OSError("busy")), patch.object( - nexent_agent.logger, "error" + with patch.object(resource_workspace.shutil, "rmtree", side_effect=OSError("busy")), patch.object( + resource_workspace.logger, "error" ) as error: nexent_agent_instance._cleanup_file_workspace() @@ -5991,7 +6019,7 @@ def forward(self, x=1): tool = NonRetrieverTool() tool._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): wrapped = _wrap_tool_with_monitoring(tool, "test_agent") result = wrapped.forward(x=42) @@ -6011,7 +6039,7 @@ async def forward(self, q="hello"): tool = AsyncTool() tool._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): wrapped = _wrap_tool_with_monitoring(tool, "agent_async") result = asyncio.get_event_loop().run_until_complete( wrapped.forward(q="world") @@ -6032,7 +6060,7 @@ def forward(self, query=""): tool = KnowledgeBaseSearchTool() tool._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): wrapped = _wrap_tool_with_monitoring(tool, "agent_r") result = wrapped.forward(query="test") @@ -6050,7 +6078,7 @@ def my_tool(x=1): my_tool._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): wrapped = _wrap_tool_with_monitoring(my_tool, "callable_agent") result = wrapped(x=21) @@ -6068,7 +6096,7 @@ async def async_func(val=0): async_func._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): wrapped = _wrap_tool_with_monitoring(async_func, "agent_ac") result = asyncio.get_event_loop().run_until_complete( wrapped(val=5) @@ -6089,7 +6117,7 @@ def __call__(self, query=""): tool = SearchMemoryTool() tool._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): wrapped = _wrap_tool_with_monitoring(tool, "agent_mem") result = wrapped(query="hi") @@ -6107,7 +6135,7 @@ class PlainObject: tool = PlainObject() tool._nexent_monitoring_wrapped = False - with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm): + with patch.object(resource_monitoring, "get_monitoring_manager", return_value=mm): result = _wrap_tool_with_monitoring(tool, "agent_plain") assert result is tool @@ -6440,7 +6468,7 @@ def test_cleanup_sandbox_minio_sync_success(self, nexent_agent_instance, mock_co mock_sandbox_module.cleanup_executor = mock_cleanup with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): nexent_agent_instance._cleanup_sandbox() @@ -6474,7 +6502,7 @@ def test_cleanup_sandbox_minio_sync_exception(self, nexent_agent_instance, mock_ mock_sandbox_module.cleanup_executor = mock_cleanup with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): nexent_agent_instance._cleanup_sandbox() @@ -6495,7 +6523,7 @@ def test_cleanup_sandbox_system_scope_releases_executor(self, nexent_agent_insta mock_sandbox_module.release_python_executor = mock_release with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): nexent_agent_instance._cleanup_sandbox() @@ -6519,7 +6547,7 @@ def test_cleanup_sandbox_releases_all_agent_executors_in_reverse_order( mock_sandbox_module.release_python_executor = mock_release with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): nexent_agent_instance._cleanup_sandbox() @@ -6545,7 +6573,7 @@ def test_cleanup_sandbox_deduplicates_executor_without_root_agent( with patch.dict( "sys.modules", - {"sdk.nexent.core.agents.sandbox": mock_sandbox_module}, + {"nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module}, ): nexent_agent_instance._cleanup_sandbox() @@ -6573,7 +6601,7 @@ def test_cleanup_sandbox_minio_sync_no_uploaded_files(self, nexent_agent_instanc mock_sandbox_module.cleanup_executor = mock_cleanup with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): nexent_agent_instance._cleanup_sandbox() @@ -6642,7 +6670,7 @@ def test_sandbox_build_local_level_skips_warmup(self, nexent_agent_instance, moc ) with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): result, mock_cls = self._run_create_single_agent( nexent_agent_instance, mock_model_config, mock_core_agent, agent_config @@ -6679,7 +6707,7 @@ def test_sandbox_build_docker_level_with_warmup(self, nexent_agent_instance, moc ) with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): result, mock_cls = self._run_create_single_agent( nexent_agent_instance, mock_model_config, mock_core_agent, agent_config @@ -6730,7 +6758,7 @@ def test_docker_sandbox_binds_skill_script_execution_backend( with patch.dict( "sys.modules", - {"sdk.nexent.core.agents.sandbox": sandbox_module}, + {"nexent.core.agents.sandbox.config": sandbox_module, "nexent.core.agents.sandbox.execution": sandbox_module}, ), patch.object( nexent_agent_instance, "create_tool", return_value=tool ), patch.object( @@ -6788,7 +6816,7 @@ def test_sandbox_build_docker_warmup_fallback_local(self, nexent_agent_instance, ) with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): result, _ = self._run_create_single_agent( nexent_agent_instance, mock_model_config, mock_core_agent, agent_config @@ -6822,7 +6850,7 @@ def test_sandbox_build_warmup_error(self, nexent_agent_instance, mock_model_conf ) with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }): # Should NOT raise despite warm-up failure result, _ = self._run_create_single_agent( @@ -6879,7 +6907,7 @@ def test_parent_and_managed_agent_share_session_container_with_distinct_kernels( ) with patch.dict("sys.modules", { - "sdk.nexent.core.agents.sandbox": mock_sandbox_module, + "nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module, }), patch.object( nexent_agent, "CoreAgent", @@ -6954,7 +6982,7 @@ def test_agent_tree_rejects_multiple_session_container_groups( with patch.dict( "sys.modules", - {"sdk.nexent.core.agents.sandbox": mock_sandbox_module}, + {"nexent.core.agents.sandbox.config": mock_sandbox_module, "nexent.core.agents.sandbox.execution": mock_sandbox_module}, ), patch.object(nexent_agent, "CoreAgent", return_value=MagicMock()): root = nexent_agent_instance.create_single_agent(parent_config) wrapper = nexent_agent.CoreAgent.call_args.kwargs['managed_agents'][0] diff --git a/test/sdk/core/agents/test_nexent_agent_bind_workspace.py b/test/sdk/core/agents/test_nexent_agent_bind_workspace.py index ad5c6fa25b..5854c97b2b 100644 --- a/test/sdk/core/agents/test_nexent_agent_bind_workspace.py +++ b/test/sdk/core/agents/test_nexent_agent_bind_workspace.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import json import logging from concurrent.futures import CancelledError @@ -8,9 +10,9 @@ import pytest -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.sandbox import SandboxConfig, SandboxLevel -from nexent.core.agents.sandbox_workspace import SandboxWorkspace +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.sandbox.config import SandboxConfig, SandboxLevel +from nexent.core.agents.resources.workspace import SandboxWorkspace from nexent.core.tools.download_from_s3_tool import DownloadFromS3Tool @@ -180,7 +182,7 @@ def test_bind_probe_checks_each_distinct_container(bind_agent, mocker): second = Mock() second.container.id = 'second-container' bind_agent._sandbox_executors.append(second) - probe = mocker.patch('nexent.core.agents.nexent_agent.probe_workspace') + probe = mocker.patch('nexent.core.agents.resources.workspace.probe_workspace') grant = mocker.patch.object(bind_agent, '_grant_sandbox_output_access') bind_agent._push_file_workspace_to_sandbox() assert probe.call_count == 2 @@ -211,7 +213,7 @@ def test_direct_retry_cancellation_is_not_wrapped(bind_agent): def test_posix_bind_access_grants_permissions_before_probe(bind_agent, mocker): container = bind_agent._sandbox_executors[0].container grant = mocker.patch.object(bind_agent, '_grant_sandbox_output_access') - probe = mocker.patch('nexent.core.agents.nexent_agent.probe_workspace') + probe = mocker.patch('nexent.core.agents.resources.workspace.probe_workspace') bind_agent._verify_bind_workspace_access([container], PurePosixPath('/host/run'), bind_agent.workspace_mapping) grant.assert_called_once_with(container, bind_agent.workspace_mapping.container_root) probe.assert_called_once_with(container, bind_agent.workspace_mapping.container_root) diff --git a/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py b/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py index 7ca3dcba1d..d4264e4a75 100644 --- a/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py +++ b/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Focused factory tests for ContextRuntime selection in NexentAgent.""" from __future__ import annotations @@ -8,11 +10,11 @@ from threading import Event from unittest.mock import MagicMock, patch -from sdk.nexent.core.agents.agent_model import AgentConfig, ModelConfig, ToolConfig -from sdk.nexent.core.agents.context import ContextItemInput -from sdk.nexent.core.agents.nexent_agent import NexentAgent -from sdk.nexent.core.agents.context import ContextManagerConfig -from sdk.nexent.core.utils.observer import MessageObserver +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.context import ContextItemInput +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.context import ContextManagerConfig +from nexent.core.utils.observer import MessageObserver def test_ut_sdk_dpr_004_factory_filters_prompt_and_execution_tools_by_run_policy(): @@ -50,7 +52,7 @@ def fake_core_agent(**kwargs): with patch.object(factory, "create_model", return_value=MagicMock()), \ patch.object(factory, "create_tool", side_effect=lambda tool: MagicMock(name=tool.name)), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent", side_effect=fake_core_agent): + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", side_effect=fake_core_agent): factory.create_single_agent(config) factory.create_single_agent(second_config) @@ -102,7 +104,7 @@ def fake_core_agent(**kwargs): return MagicMock() with patch.object(factory, "create_model", return_value=MagicMock()), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent", side_effect=fake_core_agent): + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", side_effect=fake_core_agent): factory.create_single_agent(config) # NOSONAR - trusted local test double. factory.create_single_agent(config) assert config.context_items == [item] @@ -133,7 +135,7 @@ def fake_core_agent(**kwargs): return MagicMock() with patch.object(factory, "create_model", return_value=MagicMock()), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent", side_effect=fake_core_agent): + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", side_effect=fake_core_agent): factory.create_single_agent(config) runtime = captured["context_runtime"] @@ -156,7 +158,7 @@ def fake_core_agent(**kwargs): return MagicMock() with patch.object(factory, "create_model", return_value=MagicMock()), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent", side_effect=fake_core_agent): + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", side_effect=fake_core_agent): factory.create_single_agent(config) runtime = captured["context_runtime"] @@ -185,7 +187,7 @@ def fake_core_agent(**kwargs): return MagicMock() with patch.object(factory, "create_model", return_value=MagicMock()), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent", side_effect=fake_core_agent): + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", side_effect=fake_core_agent): factory.create_single_agent( # NOSONAR - trusted local test double. config, context_items_override=() ) @@ -219,7 +221,7 @@ def __init__(self, **kwargs): with patch.object(NexentAgent, "create_model", return_value=MagicMock()), \ patch.object(NexentAgent, "_cleanup_sandbox"), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent", FakeCoreAgent): + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent", FakeCoreAgent): main_agent = factory.create_single_agent( # NOSONAR - trusted local test double. root ) @@ -243,7 +245,7 @@ def test_clarification_policy_is_not_injected_into_other_protocols(kind): config = AgentConfig(name="agent", description="test", model_name="main", tools=[], context_items=[item], output_protocol="final_envelope" if kind == "nl2skill" else "code_action") with patch.object(factory, "create_model", return_value=MagicMock()), \ - patch("sdk.nexent.core.agents.nexent_agent.CoreAgent") as core: + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent") as core: factory.create_single_agent(config, _managed_context=kind == "child") assert core.call_args.kwargs["enable_clarification"] is False assert core.call_args.kwargs["context_runtime"].items == [item] diff --git a/test/sdk/core/agents/test_nexent_agent_sandbox_construction.py b/test/sdk/core/agents/test_nexent_agent_sandbox_construction.py index e6cf2da302..bebbb7cfb3 100644 --- a/test/sdk/core/agents/test_nexent_agent_sandbox_construction.py +++ b/test/sdk/core/agents/test_nexent_agent_sandbox_construction.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Construction and fallback preserve the run's mapping and resource ownership.""" from concurrent.futures import CancelledError @@ -5,12 +7,14 @@ from types import SimpleNamespace from unittest.mock import Mock +from nexent.core.agents.resources import workspace as workspace_resources + import pytest -from nexent.core.agents import nexent_agent as agent_module -from nexent.core.agents import sandbox -from nexent.core.agents.agent_model import AgentConfig, ToolConfig -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.cores.smolagents import agent as agent_module +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.utils.observer import MessageObserver @@ -132,7 +136,7 @@ def test_deferred_strict_warmup_failure_releases_executor(construction, mocker, assert wrapper.remote is None assert c.factory._sandbox_executors == [] assert c.factory._sandbox_skill_runners == [] - release.assert_called_once_with(c.executor, agent_module.logger, timeout=5.0) + release.assert_called_once_with(c.executor, workspace_resources.logger, timeout=5.0) wrapper.cleanup() release.assert_called_once() diff --git a/test/sdk/core/agents/test_nexent_agent_tool_fastpath.py b/test/sdk/core/agents/test_nexent_agent_tool_fastpath.py index d0ca883632..b149da3ad6 100644 --- a/test/sdk/core/agents/test_nexent_agent_tool_fastpath.py +++ b/test/sdk/core/agents/test_nexent_agent_tool_fastpath.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Agent factories defer initialization and release only resources they acquire.""" import json @@ -7,13 +9,15 @@ from types import SimpleNamespace from unittest.mock import Mock +from nexent.core.agents.resources import workspace as workspace_resources + import pytest -from nexent.core.agents import nexent_agent as agent_module -from nexent.core.agents import sandbox -from nexent.core.agents.agent_model import AgentConfig, ToolConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.tool_fastpath import DeferredToolExecutor +from nexent.core.agents.cores.smolagents import agent as agent_module +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool from nexent.core.utils.observer import MessageObserver @@ -178,7 +182,7 @@ def test_conflicting_session_group_releases_new_lease_before_skill_runner_creati e.factory.agent = e.factory.create_single_agent(e.config, _sandbox_tree_context=tree) with pytest.raises(RuntimeError, match="multiple session sandbox containers"): e.factory.agent.python_executor('import math') - e.cleanup.assert_called_once_with(e.remotes[0], agent_module.logger, timeout=5.0) + e.cleanup.assert_called_once_with(e.remotes[0], workspace_resources.logger, timeout=5.0) e.runner.assert_not_called() assert e.factory._sandbox_executors == [] @@ -218,7 +222,7 @@ def test_failed_child_activation_retains_shared_skills_until_root_cleanup(factor assert root._sandbox_skill_runners == [runner] assert e.factory._sandbox_skill_runners == [] assert e.factory._sandbox_executors == [] - e.cleanup.assert_called_once_with(e.remotes[0], agent_module.logger, timeout=5.0) + e.cleanup.assert_called_once_with(e.remotes[0], workspace_resources.logger, timeout=5.0) e.factory._cleanup_sandbox() runner.cleanup.assert_not_called() root._close_subagent_invocations() @@ -285,7 +289,7 @@ def test_unwrapped_root_is_also_released_when_not_registered(factory_execution): executor = Mock() e.factory.agent = SimpleNamespace(python_executor=executor) e.factory._cleanup_sandbox() - e.cleanup.assert_called_once_with(executor, agent_module.logger, timeout=5.0) + e.cleanup.assert_called_once_with(executor, workspace_resources.logger, timeout=5.0) assert e.factory.agent.python_executor is None diff --git a/test/sdk/core/agents/test_nexent_agent_tool_fastpath_policy.py b/test/sdk/core/agents/test_nexent_agent_tool_fastpath_policy.py index a377cb32ad..8020dce941 100644 --- a/test/sdk/core/agents/test_nexent_agent_tool_fastpath_policy.py +++ b/test/sdk/core/agents/test_nexent_agent_tool_fastpath_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run-local tool policy and deferred official-tool registration must agree.""" from contextlib import nullcontext @@ -7,11 +8,11 @@ import pytest -from nexent.core.agents import nexent_agent as agent_module -from nexent.core.agents import sandbox -from nexent.core.agents.agent_model import AgentConfig, ToolConfig -from nexent.core.agents.nexent_agent import NexentAgent -from nexent.core.agents.tool_fastpath import DeferredToolExecutor +from nexent.core.agents.cores.smolagents import agent as agent_module +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.contracts.config import AgentConfig, ToolConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.tools.knowledge_base_search_tool import KnowledgeBaseSearchTool from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool from nexent.core.utils.observer import MessageObserver diff --git a/test/sdk/core/agents/test_output_protocol.py b/test/sdk/core/agents/test_output_protocol.py index 591d4c9fc1..1fc6434b77 100644 --- a/test/sdk/core/agents/test_output_protocol.py +++ b/test/sdk/core/agents/test_output_protocol.py @@ -1,15 +1,8 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Acceptance tests for the closed CodeAgent output protocol.""" import pytest -from nexent.core.agents.output_protocol import ( - ExecutableAction, - ExplicitFinalAnswer, - ModelOutputProtocolError, - NonterminalThought, - ProtocolErrorReason, - classify_model_output, - has_meaningful_visible_content, -) +from nexent.core.agents.cores.smolagents.output_protocol import ExecutableAction, ExplicitFinalAnswer, ModelOutputProtocolError, NonterminalThought, ProtocolErrorReason, classify_model_output, has_meaningful_visible_content @pytest.mark.parametrize( diff --git a/test/sdk/core/agents/test_plan_repo.py b/test/sdk/core/agents/test_plan_repo.py index c26e6936c1..49a645c8fb 100644 --- a/test/sdk/core/agents/test_plan_repo.py +++ b/test/sdk/core/agents/test_plan_repo.py @@ -1,4 +1,5 @@ -"""Unit tests for sdk.nexent.core.agents.plan_repo. +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Unit tests for sdk.nexent.core.agents.cores.smolagents.plan_repo. PlanRepo is a pure stdlib module: it persists plans to Redis (when a client is provided) and always mirrors them to a thread-local in-memory dict as a @@ -36,8 +37,8 @@ def _pkg(name, path): core_pkg.agents = agents_pkg -MODULE_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "plan_repo.py" -MODULE_NAME = "sdk.nexent.core.agents.plan_repo" +MODULE_PATH = REPO_ROOT / "sdk/nexent/core/agents/cores/smolagents/plan_repo.py" +MODULE_NAME = "sdk.nexent.core.agents.cores.smolagents.plan_repo" spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) plan_repo_module = importlib.util.module_from_spec(spec) sys.modules[MODULE_NAME] = plan_repo_module diff --git a/test/sdk/core/agents/test_resource_orchestration_boundaries.py b/test/sdk/core/agents/test_resource_orchestration_boundaries.py new file mode 100644 index 0000000000..1e1ee86f94 --- /dev/null +++ b/test/sdk/core/agents/test_resource_orchestration_boundaries.py @@ -0,0 +1,175 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Shared resources have no engine loop and retain scoped cleanup ownership.""" + +import ast +import importlib +import pickle +import subprocess +import sys +from pathlib import Path +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.contracts.config import AgentConfig, ModelConfig, ToolConfig +from nexent.core.agents.contracts.run import AgentHistory, AgentRunInfo +from nexent.core.agents.orchestration import execution +from nexent.core.agents.resources.provider import AgentResources +from nexent.core.utils.observer import MessageObserver + + +def resource_owner(**kwargs): + return AgentResources(observer=MessageObserver(), model_config_list=[], stop_event=Event(), **kwargs) + + +@pytest.mark.parametrize("failure", [None, "validate", "prepare", "run"]) +def test_common_adapter_lifetime_closes_partial_preparation_without_core_callback(monkeypatch, failure): + from nexent.core.agents.factory import AgentCoreFactory + + adapter = Mock() + if failure: + getattr(adapter, failure).side_effect = RuntimeError(failure) + create = Mock(return_value=adapter) + monkeypatch.setattr(AgentCoreFactory, "create", create) + info = object() + if failure: + with pytest.raises(RuntimeError, match=failure): + execution.execute_adapter(info) + else: + assert execution.execute_adapter(info) is adapter.run.return_value + create.assert_called_once_with(info) + adapter.close.assert_called_once_with() + if failure == "validate": + adapter.prepare.assert_not_called() + if failure in {"validate", "prepare"}: + adapter.run.assert_not_called() + + +@pytest.mark.parametrize("failure", [None, "finalize", "drain", "sandbox"]) +def test_shared_close_order_idempotence_and_failed_drain_retains_owner(monkeypatch, failure): + owner = resource_owner() + calls = [] + methods = {"drain": "_close_subagent_invocations", "finalize": "_finalize_file_workspace", + "workspace": "_cleanup_file_workspace", "sandbox": "_cleanup_sandbox"} + for phase, name in methods.items(): + def run_phase(phase=phase): + calls.append(phase) + if phase == failure: + raise RuntimeError(phase) + monkeypatch.setattr(owner, name, run_phase) + if failure: + with pytest.raises(RuntimeError, match=failure): + owner.close() + else: + owner.close() + assert calls == (["drain"] if failure == "drain" else ["drain", "finalize", "workspace", "sandbox"]) + assert owner._resources_closed is (failure not in {"drain", "sandbox"}) + if owner._resources_closed: + owner.close() + assert calls == ["drain", "finalize", "workspace", "sandbox"] + + +def test_native_resources_never_construct_default_core(monkeypatch): + from nexent.core.agents.cores.smolagents.agent import NexentAgent + + monkeypatch.setattr(NexentAgent, "__init__", Mock(side_effect=AssertionError("default core created"))) + owner = resource_owner() + assert not isinstance(owner, NexentAgent) + assert not hasattr(owner, "create_single_agent") + assert not hasattr(owner, "agent_run_with_observer") + owner.close() + NexentAgent.__init__.assert_not_called() + + +def test_resource_child_dispatch_is_injected_and_scope_is_reclaimed(): + selected = [] + def create(config, scope): + selected.append((config, scope)) + return lambda task: (config.agent_core, task) + owner = resource_owner(child_invocation_factory=create) + config = SimpleNamespace(agent_core="jiuwen") + with owner._subagent_invocation(config, None, {}) as invoke: + assert invoke("current task") == ("jiuwen", "current task") + assert len(owner._subagent_invocations) == 1 + assert selected[0][0] is config + assert not owner._subagent_invocations + owner.close() + + +def test_default_core_resource_owner_closes_after_construction_failure(monkeypatch): + from nexent.core.agents.cores.smolagents import runner + + owner = Mock() + owner.create_single_agent.side_effect = RuntimeError("partial setup") + monkeypatch.setattr(runner, "NexentAgent", Mock(return_value=owner)) + info = SimpleNamespace(observer=MessageObserver(), model_config_list=[], stop_event=Event(), + redis_client=None, conversation_id=None, user_id=None, cancellation_scope=None, + agent_config=object(), context_input=None) + with pytest.raises(RuntimeError, match="partial setup"): + runner._run_with_resources(info) + owner.close.assert_called_once_with() + owner.agent_run_with_observer.assert_not_called() + + +@pytest.mark.parametrize("name,canonical", [ + ("AgentConfig", AgentConfig), ("ModelConfig", ModelConfig), ("ToolConfig", ToolConfig), + ("AgentRunInfo", AgentRunInfo), ("AgentHistory", AgentHistory), +]) +def test_configuration_types_use_their_canonical_owner(name, canonical): + owner = importlib.import_module(canonical.__module__) + assert getattr(owner, name) is canonical + assert pickle.loads(pickle.dumps(canonical)) is canonical + + +def test_output_protocol_error_is_a_single_neutral_contract(): + from nexent.core.agents.contracts.adapter import ModelOutputProtocolExhaustedError + from nexent.core.agents.cores.smolagents.output_protocol import ( + ModelOutputProtocolExhaustedError as CoreError, + ) + + assert CoreError is ModelOutputProtocolExhaustedError + assert pickle.loads(pickle.dumps(CoreError)) is ModelOutputProtocolExhaustedError + + +def test_common_layers_and_native_adapter_do_not_import_default_agent_implementation(): + root = Path(__file__).resolve().parents[4] / "sdk/nexent/core/agents" + files = list((root / "resources").glob("*.py")) + list((root / "orchestration").glob("*.py")) + files.append(root / "cores/jiuwen/adapter.py") + for file in files: + tree = ast.parse(file.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + assert "cores.smolagents" not in (node.module or ""), file + assert "nexent_agent" not in (node.module or ""), file + assert "core_agent" not in (node.module or ""), file + if file.parent.name == "resources": + assert "agents.orchestration" not in (node.module or ""), file + assert "agents.factory" not in (node.module or ""), file + for node in ast.walk(tree): + if isinstance(node, ast.Call): + assert not any(keyword.arg == "smolagents_executor" for keyword in node.keywords), file + + +def test_clean_native_import_and_resources_with_default_agent_modules_blocked(): + sdk = Path(__file__).resolve().parents[4] / "sdk" + script = f""" +import importlib.abc, sys +sys.path.insert(0, {str(sdk)!r}) +class RejectDefaultAgent(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname in {{'nexent.core.agents.cores.smolagents.agent', + 'nexent.core.agents.cores.smolagents.core_agent'}}: + raise AssertionError('default Agent implementation imported: ' + fullname) +sys.meta_path.insert(0, RejectDefaultAgent()) +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.resources.provider import AgentResources +from nexent.core.utils.observer import MessageObserver +from threading import Event +owner = AgentResources(observer=MessageObserver(), model_config_list=[], stop_event=Event()) +owner.close() +print('NATIVE_IMPORT_PASS') +""" + result = subprocess.run([sys.executable, "-I", "-c", script], capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stderr + assert "NATIVE_IMPORT_PASS" in result.stdout diff --git a/test/sdk/core/agents/test_run_agent.py b/test/sdk/core/agents/test_run_agent.py index e9914d25c6..41e3e66a38 100644 --- a/test/sdk/core/agents/test_run_agent.py +++ b/test/sdk/core/agents/test_run_agent.py @@ -1,3 +1,6 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +from pathlib import Path import asyncio import types import json @@ -60,7 +63,7 @@ def test_log_memory_value_assessment_for_store_attempt(caplog): ) agent = types.SimpleNamespace(tools={"store_memory": store_tool}) - run_agent._log_memory_value_assessment(agent) + smol_runner._log_memory_value_assessment(agent) assert "event=memory_value_assessment" in caplog.text assert "decision=store_attempted" in caplog.text @@ -80,10 +83,10 @@ def test_log_memory_value_assessment_for_skip_and_unavailable(caplog): last_outcome="not_invoked", ) - run_agent._log_memory_value_assessment( + smol_runner._log_memory_value_assessment( types.SimpleNamespace(tools={"store_memory": store_tool}) ) - run_agent._log_memory_value_assessment(types.SimpleNamespace(tools={})) + smol_runner._log_memory_value_assessment(types.SimpleNamespace(tools={})) assert "decision=skip" in caplog.text assert "last_outcome=not_invoked" in caplog.text @@ -260,7 +263,7 @@ def __init__(self, *args, **kwargs): ChatCompletionMessage=mock_openai_chat_completion_message ), "openai.types.chat.chat_completion_message_param": MagicMock(), - # exa_py is imported by sdk.nexent.core.tools – provide dummy to skip real import + # exa_py is imported by nexent.core.tools – provide dummy to skip real import "exa_py": MagicMock(Exa=MagicMock()), "tavily": MagicMock(TavilyClient=MagicMock()), "linkup": MagicMock( @@ -275,14 +278,14 @@ def __init__(self, *args, **kwargs): "nexent.skills.skill_manager": MagicMock(), } -# Stub the gateway bridge: ``sdk.nexent.memory.embedding_model`` and the vector +# Stub the gateway bridge: ``nexent.memory.embedding_model`` and the vector # database core import gateway adapter symbols at module level, and the real # gateway eagerly registers every vendor adapter (absolute ``nexent.*`` imports # that the mocked environment cannot satisfy). The mocked modules only need the # adapter names for typing / construction stubs. -_gateway_mod = ModuleType("sdk.nexent.core.gateway") +_gateway_mod = ModuleType("nexent.core.gateway") _gateway_mod.__path__ = [] -_gateway_modality_mod = ModuleType("sdk.nexent.core.gateway.modality") +_gateway_modality_mod = ModuleType("nexent.core.gateway.modality") _gateway_modality_mod.__path__ = [] for _name in ( "OpenAICompatibleEmbeddingAdapter", @@ -293,28 +296,42 @@ def __init__(self, *args, **kwargs): setattr(_gateway_modality_mod, _name, MagicMock(name=f"gateway.modality.{_name}")) _gateway_mod.modality = _gateway_modality_mod _gateway_mod.EmbeddingContext = MagicMock(name="gateway.EmbeddingContext") -sys.modules["sdk.nexent.core.gateway"] = _gateway_mod -sys.modules["sdk.nexent.core.gateway.modality"] = _gateway_modality_mod +sys.modules["nexent.core.gateway"] = _gateway_mod +sys.modules["nexent.core.gateway.modality"] = _gateway_modality_mod # --------------------------------------------------------------------------- # Import modules under test with patched dependencies in place # --------------------------------------------------------------------------- +# Apply existing dependency doubles at canonical lookup sites after module migration. +for _legacy_name, _double in list(module_mocks.items()): + if _legacy_name.startswith("nexent"): + _canonical_name = _legacy_name.removeprefix("sdk.") + if _canonical_name == "nexent.core.agents.cores.smolagents.core_agent": + _canonical_name = "nexent.core.agents.cores.smolagents.core_agent" + module_mocks[_canonical_name] = _double +for _package in ("nexent", "nexent.core", "nexent.core.agents", "nexent.core.models"): + _package_double = module_mocks.setdefault(_package, types.ModuleType(_package)) + _package_double.__path__ = [str(Path(__file__).resolve().parents[4] / "sdk" / _package.replace(".", "/"))] + +_engine_agent_double = ModuleType("nexent.core.agents.cores.smolagents.agent") +_engine_agent_double.NexentAgent = MagicMock() +module_mocks[_engine_agent_double.__name__] = _engine_agent_double + with patch.dict("sys.modules", module_mocks): - from sdk.nexent.core.utils.observer import MessageObserver, ProcessType # noqa: E402 - from sdk.nexent.core.agents.agent_model import ( - AgentRunInfo, - ModelConfig, - AgentConfig, - ToolConfig, - ) # noqa: E402 - import sdk.nexent.core.agents.run_agent as run_agent # noqa: E402 - from sdk.nexent.consts.mcp_errors import ( # noqa: E402 + from nexent.core.utils.observer import MessageObserver, ProcessType # noqa: E402 + from nexent.core.agents.contracts.run import AgentRunInfo + from nexent.core.agents.contracts.config import ModelConfig, AgentConfig, ToolConfig # noqa: E402 + import nexent.core.agents.orchestration.entry as run_agent # noqa: E402 + from nexent.core.agents.cores.smolagents import runner as smol_runner + from nexent.consts.mcp_errors import ( # noqa: E402 is_mcp_connection_timeout_error, MCPToolTimeoutError, is_mcp_timeout_error, propagate_mcp_timeout, ) +sys.modules[smol_runner.__name__] = smol_runner + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -384,7 +401,7 @@ def _mock_managed_mcp(monkeypatch): __exit__=MagicMock(return_value=None), ) factory = MagicMock(return_value=context) - monkeypatch.setattr(run_agent, "ManagedMCPToolCollection", factory) + monkeypatch.setattr(smol_runner, "ManagedMCPToolCollection", factory) return factory, collection @@ -401,14 +418,14 @@ def test_agent_run_thread_local_flow(basic_agent_run_info, monkeypatch, fastpath basic_agent_run_info.tool_fastpath_allowed_tools = ["ReadSkillConfigTool"] mock_nexent_instance = MagicMock(name="NexentAgentInstance") monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) # Call the function under test run_agent.agent_run_thread(basic_agent_run_info) # NexentAgent should be instantiated with observer, model_config_list, stop_event - run_agent.NexentAgent.assert_called_once_with( + smol_runner.NexentAgent.assert_called_once_with( observer=basic_agent_run_info.observer, model_config_list=basic_agent_run_info.model_config_list, stop_event=basic_agent_run_info.stop_event, @@ -462,7 +479,7 @@ def test_agent_run_thread_binds_capacity_and_budget_snapshots( ) mock_nexent_instance = MagicMock(name="NexentAgentInstance") monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) run_agent.agent_run_thread(basic_agent_run_info) @@ -519,7 +536,7 @@ def test_agent_run_thread_mcp_flow( # Patch NexentAgent mock_nexent_instance = MagicMock(name="NexentAgentInstance") monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) # Execute @@ -540,7 +557,7 @@ def test_agent_run_thread_mcp_flow( assert managed_mcp_factory.call_args.kwargs["connect_timeout_seconds"] == 10.0 # NexentAgent should be instantiated with mcp_tool_collection - run_agent.NexentAgent.assert_called_once_with( + smol_runner.NexentAgent.assert_called_once_with( observer=basic_agent_run_info.observer, model_config_list=basic_agent_run_info.model_config_list, stop_event=basic_agent_run_info.stop_event, @@ -597,7 +614,7 @@ def test_agent_run_thread_mcp_flow_with_explicit_transport( # Patch NexentAgent mock_nexent_instance = MagicMock(name="NexentAgentInstance") monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) # Execute @@ -629,7 +646,7 @@ def test_agent_run_thread_mcp_flow_mixed_formats( # Patch NexentAgent mock_nexent_instance = MagicMock(name="NexentAgentInstance") monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) # Execute @@ -951,7 +968,7 @@ def test_agent_run_thread_marks_kernel_cancellation_stopped(basic_agent_run_info instance = MagicMock() instance.create_single_agent.side_effect = CancelledError("cancelled during bootstrap") - monkeypatch.setattr(run_agent, "NexentAgent", MagicMock(return_value=instance)) + monkeypatch.setattr(smol_runner, "NexentAgent", MagicMock(return_value=instance)) run_agent.agent_run_thread(basic_agent_run_info) assert basic_agent_run_info.attempt_outcome == "stopped" assert not any("Run Agent Error" in str(call) for call in basic_agent_run_info.observer.add_message.call_args_list) @@ -966,7 +983,7 @@ def test_agent_run_thread_handles_internal_exception( failing_nexent_instance.create_single_agent.side_effect = Exception("Boom") monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=failing_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=failing_nexent_instance) ) # Execute and expect ValueError @@ -995,7 +1012,7 @@ def test_agent_run_thread_cleans_workspace_when_agent_creation_fails( mock_nexent_instance = MagicMock(name="NexentAgentInstance") mock_nexent_instance.create_single_agent.side_effect = RuntimeError("Boom") monkeypatch.setattr( - run_agent, + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance), ) @@ -1022,7 +1039,7 @@ def run_and_clean(**_kwargs): mock_nexent_instance.agent_run_with_observer.side_effect = run_and_clean monkeypatch.setattr( - run_agent, + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance), ) @@ -1115,7 +1132,7 @@ async def test_agent_run_close_releases_real_manager_capacity( monkeypatch, ): """A cooperative interrupted session returns its agent lane to zero.""" - from sdk.nexent.core.concurrency import LanePolicy + from nexent.core.concurrency import LanePolicy manager = run_agent.ThreadManager( service_name="session-lifecycle-test", @@ -1245,7 +1262,7 @@ def test_agent_run_thread_mcp_connection_error(basic_agent_run_info, monkeypatch "Couldn't connect to the MCP server" ) monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) with pytest.raises(ValueError) as exc_info: @@ -1264,7 +1281,7 @@ def test_agent_run_thread_mcp_timeout_error_is_localized(basic_agent_run_info, m "MCP tool request timed out after 60 seconds" ) monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) with pytest.raises(ValueError): @@ -1286,7 +1303,7 @@ def test_agent_run_thread_chinese_lang(basic_agent_run_info, monkeypatch): "Couldn't connect to the MCP server" ) monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(return_value=mock_nexent_instance) + smol_runner, "NexentAgent", MagicMock(return_value=mock_nexent_instance) ) with pytest.raises(ValueError): @@ -1453,7 +1470,7 @@ def create_nexent_agent(*args, **kwargs): return mock_nexent_instance monkeypatch.setattr( - run_agent, "NexentAgent", MagicMock(side_effect=create_nexent_agent) + smol_runner, "NexentAgent", MagicMock(side_effect=create_nexent_agent) ) test_var.set("preserved!") diff --git a/test/sdk/core/agents/test_sandbox_channel_readiness.py b/test/sdk/core/agents/test_sandbox_channel_readiness.py index 7621e49b61..359047df58 100644 --- a/test/sdk/core/agents/test_sandbox_channel_readiness.py +++ b/test/sdk/core/agents/test_sandbox_channel_readiness.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Channel readiness must precede execution; cancelled requests must not retry.""" import json @@ -6,7 +7,7 @@ from unittest.mock import MagicMock import pytest -from nexent.core.agents import sandbox as sb +from nexent.core.agents.sandbox import execution as sb from websocket import ( ABNF, WebSocketBadStatusException, diff --git a/test/sdk/core/agents/test_sandbox_directory_boundaries.py b/test/sdk/core/agents/test_sandbox_directory_boundaries.py new file mode 100644 index 0000000000..e9685dcbe2 --- /dev/null +++ b/test/sdk/core/agents/test_sandbox_directory_boundaries.py @@ -0,0 +1,73 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Canonical sandbox submodules keep isolation without a flat compatibility facade.""" +import ast +import importlib +import pickle +import subprocess +import sys +from pathlib import Path + +import pytest +from nexent.core.agents.sandbox import config, execution, tls + + +@pytest.mark.parametrize("name", ["SandboxLevel", "SandboxScope", "ShellPolicy", "SandboxConfig"]) +def test_configuration_types_have_a_single_canonical_definition(name): + symbol = getattr(config, name) + assert getattr(execution, name) is symbol + assert symbol.__module__ == config.__name__ + assert pickle.loads(pickle.dumps(symbol)) is symbol + + +def test_sandbox_is_a_package_without_flat_api_or_patch_forwarding(): + package = importlib.import_module("nexent.core.agents.sandbox") + assert package.__spec__.submodule_search_locations is not None + assert Path(config.__file__).parent == Path(execution.__file__).parent + assert Path(package.__file__).name == "__init__.py" + assert not Path(package.__file__).parent.with_suffix(".py").exists() + assert "__getattr__" not in vars(package) + assert "SandboxConfig" not in vars(package) + assert "build_python_executor" not in vars(package) + + +def test_configuration_import_does_not_load_execution_or_tls(): + sdk = Path(__file__).resolve().parents[4] / "sdk" + code = f''' +import sys,importlib.abc +sys.path.insert(0,{str(sdk)!r}) +class ConfigOnly(importlib.abc.MetaPathFinder): + def find_spec(self,fullname,path=None,target=None): + if fullname in {{'nexent.core.agents.sandbox.execution','nexent.core.agents.sandbox.tls'}}: + raise AssertionError(fullname) +sys.meta_path.insert(0,ConfigOnly()) +from nexent.core.agents.sandbox.config import SandboxConfig +assert SandboxConfig().level.value == 'local' +print('CONFIG_ONLY_PASS') +''' + result = subprocess.run([sys.executable, "-I", "-c", code], capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stderr + assert "CONFIG_ONLY_PASS" in result.stdout + + +def test_tls_bootstrap_is_packaged_and_used_by_the_real_gateway_command(): + source = Path(tls.__file__).with_name("tls_bootstrap.py").read_text(encoding="utf-8") + assert tls.TLS_BOOTSTRAP == source + command = execution._kernel_gateway_command("test-host") + assert command[:5] == ["python", "-c", source, "test-host", tls.TLS_DIRECTORY] + compile(command[2], "tls_bootstrap.py", "exec") + assert "nexent.core" not in source + + +def test_sandbox_only_uses_explicit_library_compatibility(): + folder = Path(execution.__file__).parent + for name in ("config.py", "execution.py", "tls.py", "tls_bootstrap.py"): + for node in ast.walk(ast.parse(folder.joinpath(name).read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom): + assert "smolagents" not in (node.module or ""), name + assert "cores." not in (node.module or ""), name + assert "orchestration" not in (node.module or ""), name + options = config.SandboxConfig.from_dict({"level": "docker", "failure_policy": "error"}) + assert options.level is config.SandboxLevel.DOCKER + assert options.failure_policy == "error" + with pytest.raises(ValueError, match="workspace mode"): + config.SandboxConfig(workspace_mode="unknown") diff --git a/test/sdk/core/agents/test_sandbox_lease_cleanup.py b/test/sdk/core/agents/test_sandbox_lease_cleanup.py index 645df83339..8d89c94068 100644 --- a/test/sdk/core/agents/test_sandbox_lease_cleanup.py +++ b/test/sdk/core/agents/test_sandbox_lease_cleanup.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """A failed lease cleanup cannot terminate a sibling's shared container.""" from concurrent.futures import CancelledError, TimeoutError from unittest.mock import Mock import pytest -from nexent.core.agents import sandbox +from nexent.core.agents.sandbox import execution as sandbox from nexent.core.concurrency import RunCancellationScope diff --git a/test/sdk/core/agents/test_sandbox_proxy.py b/test/sdk/core/agents/test_sandbox_proxy.py index e20b865ea0..74a9ac30cc 100644 --- a/test/sdk/core/agents/test_sandbox_proxy.py +++ b/test/sdk/core/agents/test_sandbox_proxy.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Exercise real websocket-client proxy selection without external network I/O.""" import base64 @@ -10,9 +11,9 @@ from unittest.mock import MagicMock import pytest -from nexent.core.agents import sandbox as sb -from nexent.core.agents.sandbox_tls import SandboxTLSClient -from nexent.core.agents.sandbox_tls_bootstrap import generate_identity +from nexent.core.agents.sandbox import execution as sb +from nexent.core.agents.sandbox.tls import SandboxTLSClient +from nexent.core.agents.sandbox.tls_bootstrap import generate_identity from websocket import WebSocketException from websocket import _core, _http from websocket._url import get_proxy_info diff --git a/test/sdk/core/agents/test_sandbox_system_network.py b/test/sdk/core/agents/test_sandbox_system_network.py index 8d6ec4c22d..45aba5ad11 100644 --- a/test/sdk/core/agents/test_sandbox_system_network.py +++ b/test/sdk/core/agents/test_sandbox_system_network.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Native system endpoints and cancellation ownership regressions.""" import logging @@ -7,7 +8,7 @@ from unittest.mock import MagicMock import pytest -from nexent.core.agents import sandbox as sb +from nexent.core.agents.sandbox import execution as sb @pytest.fixture diff --git a/test/sdk/core/agents/test_sandbox_tls.py b/test/sdk/core/agents/test_sandbox_tls.py index c0576dd071..4520c0b169 100644 --- a/test/sdk/core/agents/test_sandbox_tls.py +++ b/test/sdk/core/agents/test_sandbox_tls.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Verified TLS transport and non-destructive migration of legacy containers.""" import io @@ -13,10 +14,10 @@ import pytest from cryptography import x509 -from nexent.core.agents import sandbox -from nexent.core.agents import sandbox_tls as tls -from nexent.core.agents import sandbox_tls_bootstrap as bootstrap -from nexent.core.agents.sandbox_tls_bootstrap import generate_identity +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.sandbox import tls as tls +from nexent.core.agents.sandbox import tls_bootstrap as bootstrap +from nexent.core.agents.sandbox.tls_bootstrap import generate_identity from requests.exceptions import SSLError diff --git a/test/sdk/core/agents/test_sandbox_tls_files.py b/test/sdk/core/agents/test_sandbox_tls_files.py index e7793d579e..1feddcd221 100644 --- a/test/sdk/core/agents/test_sandbox_tls_files.py +++ b/test/sdk/core/agents/test_sandbox_tls_files.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Real POSIX filesystem boundaries of the container's TLS bootstrap.""" import importlib.util @@ -18,7 +19,7 @@ @pytest.fixture def bootstrap(monkeypatch): - source = Path(__file__).resolve().parents[4] / 'sdk/nexent/core/agents/sandbox_tls.py' + source = Path(__file__).resolve().parents[4] / 'sdk/nexent/core/agents/sandbox/tls.py' spec = importlib.util.spec_from_file_location('standalone_tls', source) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) diff --git a/test/sdk/core/agents/test_sandbox_workspace.py b/test/sdk/core/agents/test_sandbox_workspace.py index 18a9313534..809b7698e6 100644 --- a/test/sdk/core/agents/test_sandbox_workspace.py +++ b/test/sdk/core/agents/test_sandbox_workspace.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import json import logging from pathlib import Path, PurePosixPath, PureWindowsPath @@ -5,8 +6,8 @@ from unittest.mock import Mock import pytest -from nexent.core.agents import sandbox -from nexent.core.agents.sandbox_workspace import SandboxWorkspace, probe_workspace +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.resources.workspace import SandboxWorkspace, probe_workspace from nexent.core.tools.create_file_tool import CreateFileTool from nexent.core.tools.read_file_tool import ReadFileTool from nexent.core.tools.upload_to_s3_tool import UploadToS3Tool diff --git a/test/sdk/core/agents/test_sandbox_workspace_config.py b/test/sdk/core/agents/test_sandbox_workspace_config.py index e1dd79ea8b..73bce99f6f 100644 --- a/test/sdk/core/agents/test_sandbox_workspace_config.py +++ b/test/sdk/core/agents/test_sandbox_workspace_config.py @@ -1,5 +1,6 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import pytest -from nexent.core.agents.sandbox import SandboxConfig +from nexent.core.agents.sandbox.config import SandboxConfig def test_bind_requires_posix_container_root(): diff --git a/test/sdk/core/agents/test_sdk_langfuse_integration.py b/test/sdk/core/agents/test_sdk_langfuse_integration.py index 279144b6b6..bb920dea8e 100644 --- a/test/sdk/core/agents/test_sdk_langfuse_integration.py +++ b/test/sdk/core/agents/test_sdk_langfuse_integration.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ SDK Integration Test with Real Model and LangFuse Tracing @@ -21,7 +22,7 @@ from unittest.mock import MagicMock from nexent.core.agents.context.handlers import register_all -from nexent.core.context_runtime.managed.runtime import ManagedContextRuntime +from nexent.core.agents.cores.smolagents.context import ManagedContextRuntime from nexent.core.models.openai_llm import OpenAIModel diff --git a/test/sdk/core/agents/test_subagent_invocation_wrapper.py b/test/sdk/core/agents/test_subagent_invocation_wrapper.py index 181b95a579..d54d3e4750 100644 --- a/test/sdk/core/agents/test_subagent_invocation_wrapper.py +++ b/test/sdk/core/agents/test_subagent_invocation_wrapper.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Invocation factories preserve dispatch and balanced observer events.""" from contextlib import contextmanager from unittest.mock import Mock import pytest -from nexent.core.agents.subagent_wrapper import SubAgentDefinition, SubAgentToolWrapper +from nexent.core.agents.resources.subagents import SubAgentDefinition, SubAgentToolWrapper @pytest.mark.parametrize("entry", ["call", "forward"]) diff --git a/test/sdk/core/agents/test_subagent_resource_ownership.py b/test/sdk/core/agents/test_subagent_resource_ownership.py index 2ba53e52b9..cb1c7e99ff 100644 --- a/test/sdk/core/agents/test_subagent_resource_ownership.py +++ b/test/sdk/core/agents/test_subagent_resource_ownership.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Invocation drains and shared skills obey root resource ownership.""" from threading import Event, Lock @@ -6,8 +7,8 @@ import pytest -from nexent.core.agents import sandbox -from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.cores.smolagents.agent import NexentAgent from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated diff --git a/test/sdk/core/agents/test_subagent_wrapper.py b/test/sdk/core/agents/test_subagent_wrapper.py index ba336f9962..696be485c2 100644 --- a/test/sdk/core/agents/test_subagent_wrapper.py +++ b/test/sdk/core/agents/test_subagent_wrapper.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Unit tests for sub-agent wrapper delegation and observer boundaries.""" from __future__ import annotations @@ -6,7 +7,7 @@ import pytest -from nexent.core.agents.subagent_wrapper import SubAgentToolWrapper, _default_task_extractor +from nexent.core.agents.resources.subagents import SubAgentToolWrapper, _default_task_extractor from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError diff --git a/test/sdk/core/agents/test_tool_fastpath_executor.py b/test/sdk/core/agents/test_tool_fastpath_executor.py index 239ee5ab3d..cca4b914fb 100644 --- a/test/sdk/core/agents/test_tool_fastpath_executor.py +++ b/test/sdk/core/agents/test_tool_fastpath_executor.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Deferred execution preserves values, rejects replay and owns acquired resources.""" import json @@ -9,8 +10,8 @@ from smolagents.default_tools import FinalAnswerTool from smolagents.local_python_executor import InterpreterError, LocalPythonExecutor -from nexent.core.agents import sandbox -from nexent.core.agents.tool_fastpath import DeferredToolExecutor +from nexent.core.agents.sandbox import execution as sandbox +from nexent.core.agents.cores.smolagents.tool_fastpath import DeferredToolExecutor from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool diff --git a/test/sdk/core/agents/test_tool_fastpath_grammar.py b/test/sdk/core/agents/test_tool_fastpath_grammar.py index 7067e00617..64df442dee 100644 --- a/test/sdk/core/agents/test_tool_fastpath_grammar.py +++ b/test/sdk/core/agents/test_tool_fastpath_grammar.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Whole-action admission must exclude arbitrary host Python execution.""" import json @@ -5,8 +6,8 @@ import pytest -from nexent.core.agents import tool_fastpath -from nexent.core.agents.tool_fastpath import is_simple_whitelisted_action +from nexent.core.agents.cores.smolagents import tool_fastpath +from nexent.core.agents.cores.smolagents.tool_fastpath import is_simple_whitelisted_action @pytest.mark.parametrize("code", [ diff --git a/test/sdk/core/agents/test_tool_fastpath_registry.py b/test/sdk/core/agents/test_tool_fastpath_registry.py index f805a2566a..cb167dcbe7 100644 --- a/test/sdk/core/agents/test_tool_fastpath_registry.py +++ b/test/sdk/core/agents/test_tool_fastpath_registry.py @@ -1,10 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Official class identity and trusted sources determine host tool eligibility.""" from types import SimpleNamespace import pytest -from nexent.core.agents.tool_fastpath import OFFICIAL_FASTPATH_TOOL_CLASSES, official_fastpath_tools +from nexent.core.agents.cores.smolagents.tool_fastpath import OFFICIAL_FASTPATH_TOOL_CLASSES, official_fastpath_tools from nexent.core.ext_components.aidp.aidp_search_tool import AidpSearchTool from nexent.core.tools.knowledge_base_search_tool import KnowledgeBaseSearchTool from nexent.core.tools.read_skill_config_tool import ReadSkillConfigTool diff --git a/test/sdk/core/agents/test_tool_parameters.py b/test/sdk/core/agents/test_tool_parameters.py index 98b0192570..004fd0a0e9 100644 --- a/test/sdk/core/agents/test_tool_parameters.py +++ b/test/sdk/core/agents/test_tool_parameters.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Regression tests for configured MCP call arguments.""" from copy import copy @@ -5,8 +6,8 @@ import pytest -from nexent.core.agents.tool_parameters import apply_mcp_tool_parameters -from nexent.core.agents.tool_user_context import apply_user_context_to_mcp_tool +from nexent.core.agents.resources.tool_parameters import apply_mcp_tool_parameters +from nexent.core.agents.resources.tool_user_context import apply_user_context_to_mcp_tool @pytest.mark.parametrize("style", ["keywords", "mapping", "positional"]) diff --git a/test/sdk/core/agents/test_tool_user_context.py b/test/sdk/core/agents/test_tool_user_context.py index 6ae3110fc6..c170ef999a 100644 --- a/test/sdk/core/agents/test_tool_user_context.py +++ b/test/sdk/core/agents/test_tool_user_context.py @@ -1,5 +1,6 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """ -Unit tests for sdk.nexent.core.agents.tool_user_context module. +Unit tests for sdk.nexent.core.agents.resources.tool_user_context module. Covers the user-info pass-through contract for tool-side authorization: - Conventional fields declared by a tool's input schema are injected from the @@ -22,7 +23,7 @@ def _load_tool_user_context_module(): module_path = os.path.normpath(os.path.join( os.path.dirname(__file__), "..", "..", "..", "..", - "sdk", "nexent", "core", "agents", "tool_user_context.py", + "sdk", "nexent", "core", "agents", "resources", "tool_user_context.py", )) spec = importlib.util.spec_from_file_location("tool_user_context", module_path) module = importlib.util.module_from_spec(spec) diff --git a/test/sdk/core/agents/test_workbench_identity.py b/test/sdk/core/agents/test_workbench_identity.py index 9d419895ba..b36a98badb 100644 --- a/test/sdk/core/agents/test_workbench_identity.py +++ b/test/sdk/core/agents/test_workbench_identity.py @@ -1,9 +1,10 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """SDK Workbench identity acceptance tests.""" from unittest.mock import Mock -from nexent.core.agents.agent_model import AgentConfig -from nexent.core.agents.subagent_wrapper import SubAgentToolWrapper +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.resources.subagents import SubAgentToolWrapper def test_ut_sdk_wb_001_agent_config_carries_formal_runtime_identity(): diff --git a/test/sdk/core/context_runtime/test_runtimes.py b/test/sdk/core/context_runtime/test_runtimes.py index 6363aa965d..c1b5221419 100644 --- a/test/sdk/core/context_runtime/test_runtimes.py +++ b/test/sdk/core/context_runtime/test_runtimes.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Low-dependency tests for the ContextManager runtime.""" from __future__ import annotations @@ -18,9 +19,9 @@ "nexent.core.agents.context.manager", "nexent.core.agents.context.models", "nexent.core.agents.context.run_context", - "nexent.core.agents.context.runtime", - "nexent.core.context_runtime", - "nexent.core.context_runtime.contracts", + "nexent.core.agents.cores.smolagents.context", + "nexent.core.agents.cores.smolagents.context_contracts", + "nexent.core.agents.cores.smolagents.context_contracts", "smolagents.memory", "smolagents.models", "smolagents.tools", @@ -42,7 +43,7 @@ def _bootstrap(): ("nexent.core", ROOT / "core"), ("nexent.core.agents", ROOT / "core" / "agents"), ("nexent.core.agents.context", ROOT / "core" / "agents" / "context"), - ("nexent.core.context_runtime", ROOT / "core" / "context_runtime"), + ("nexent.core.agents.cores.smolagents.context_contracts", ROOT / "core" / "context_runtime"), ): package = types.ModuleType(name) package.__path__ = [str(path)] @@ -77,8 +78,8 @@ def to_messages(self): models_module.ContextItem = type("ContextItem", (), {}) models_module.ContextItemInput = type("ContextItemInput", (), {}) sys.modules[models_module.__name__] = models_module - _load("nexent.core.context_runtime.contracts", "core/context_runtime/contracts.py") - managed = _load("nexent.core.agents.context.runtime", "core/agents/context/runtime.py") + _load("nexent.core.agents.cores.smolagents.context_contracts", "core/context_runtime/contracts.py") + managed = _load("nexent.core.agents.cores.smolagents.context", "core/agents/context/runtime.py") return managed, snapshot @@ -124,7 +125,7 @@ def prepare_run_context(self, *, memory, fallback_system_prompt, items=None): def assemble_final_context(self, **kwargs): self.calls.append(("assemble_final_context", kwargs["purpose"], kwargs.get("tools"))) self.force_compactions.append(kwargs.get("force_compaction", False)) - contracts = sys.modules["nexent.core.context_runtime.contracts"] + contracts = sys.modules["nexent.core.agents.cores.smolagents.context_contracts"] return contracts.FinalContext( messages=[{"role": "system", "content": kwargs["purpose"]}], tools=list(kwargs.get("tools") or ()), diff --git a/test/sdk/core/model_service/test_resource_conversion.py b/test/sdk/core/model_service/test_resource_conversion.py index d64e65dd1f..13e409f43f 100644 --- a/test/sdk/core/model_service/test_resource_conversion.py +++ b/test/sdk/core/model_service/test_resource_conversion.py @@ -1,12 +1,10 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Authorized resources cross a stable conversion boundary without execution.""" import json import pytest -from nexent.core.agent_runtime_support.contracts import ( - ResourceItem, - RuntimeResourceView, -) +from nexent.core.agents.contracts.resources import ResourceItem, RuntimeResourceView from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter from nexent.core.model_service.contracts import ModelMessage, ToolCall, ToolDefinition from nexent.core.model_service.resource_conversion import ( diff --git a/test/sdk/core/models/test_sampling_defaults.py b/test/sdk/core/models/test_sampling_defaults.py index 3267da9d1c..c505844742 100644 --- a/test/sdk/core/models/test_sampling_defaults.py +++ b/test/sdk/core/models/test_sampling_defaults.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest -from nexent.core.agents.agent_model import ModelConfig +from nexent.core.agents.contracts.config import ModelConfig from nexent.core.models.openai_llm import OpenAIModel from nexent.core.models.openai_long_context_model import OpenAILongContextModel diff --git a/test/sdk/core/tools/test_file_tools_bind_boundary.py b/test/sdk/core/tools/test_file_tools_bind_boundary.py index ce51d8b8b0..cf1a795ed3 100644 --- a/test/sdk/core/tools/test_file_tools_bind_boundary.py +++ b/test/sdk/core/tools/test_file_tools_bind_boundary.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Bind-mode tools preserve successful operations and reject cross-base effects.""" import json @@ -5,7 +6,7 @@ from unittest.mock import Mock import pytest -from nexent.core.agents.sandbox_workspace import SandboxWorkspace +from nexent.core.agents.resources.workspace import SandboxWorkspace from nexent.core.tools.create_file_tool import CreateFileTool from nexent.core.tools.delete_file_tool import DeleteFileTool from nexent.core.tools.download_from_s3_tool import DownloadFromS3Tool diff --git a/test/sdk/core/tools/test_plan_tools.py b/test/sdk/core/tools/test_plan_tools.py index 249770c27d..5e6d7c0652 100644 --- a/test/sdk/core/tools/test_plan_tools.py +++ b/test/sdk/core/tools/test_plan_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Unit tests for sdk.nexent.core.tools.plan_tools. Exercises the two plan management tools (CreatePlanTool, UpdatePlanStepTool) @@ -187,11 +188,10 @@ def model_dump(self): } -agent_model_mod = types.ModuleType("sdk.nexent.core.agents.agent_model") +agent_model_mod = types.ModuleType("nexent.core.agents.contracts.config") agent_model_mod.PlanStep = _PlanStep agent_model_mod.AgentPlan = _AgentPlan -sys.modules["sdk.nexent.core.agents.agent_model"] = agent_model_mod -agents_pkg.agent_model = agent_model_mod +sys.modules["nexent.core.agents.contracts.config"] = agent_model_mod # --------------------------------------------------------------------------- diff --git a/test/sdk/core/tools/test_search_memory_tool.py b/test/sdk/core/tools/test_search_memory_tool.py index a3d2f50e42..4d17b2ccfb 100644 --- a/test/sdk/core/tools/test_search_memory_tool.py +++ b/test/sdk/core/tools/test_search_memory_tool.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Unit tests for ``SearchMemoryTool`` focusing on the Phase 4 ``MemoryContextService`` integration path. @@ -10,7 +11,7 @@ ``exclude=True`` so that Pydantic treats them as configuration metadata for the smolagents ``Tool`` schema. When no kwarg is supplied for a parameter, the FieldInfo placeholder remains on the instance, matching -the production wiring in ``sdk.nexent.core.agents.nexent_agent`` which +the production wiring in ``sdk.nexent.core.agents.cores.smolagents.agent`` which constructs the tool bare and then assigns every attribute manually. These tests follow that pattern so they stay in sync with the real runtime path. diff --git a/test/sdk/core/tools/test_workbench_skill_readers.py b/test/sdk/core/tools/test_workbench_skill_readers.py index 39215e8813..74b0b174db 100644 --- a/test/sdk/core/tools/test_workbench_skill_readers.py +++ b/test/sdk/core/tools/test_workbench_skill_readers.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Request-scoped Skill reader authorization regressions.""" import json @@ -41,7 +42,7 @@ def test_runtime_reader_and_runner_resolve_the_same_isolated_snapshot(tmp_path): import tarfile from types import SimpleNamespace from unittest.mock import MagicMock - from nexent.core.agents.sandbox import SandboxSkillScriptRunner + from nexent.core.agents.sandbox.execution import SandboxSkillScriptRunner snapshot_root = tmp_path / "snapshot" skill_root = snapshot_root / "tenant" / "mounted" diff --git a/test/sdk/monitor/test_monitoring.py b/test/sdk/monitor/test_monitoring.py index 9ffe59dbfa..0edd589958 100644 --- a/test/sdk/monitor/test_monitoring.py +++ b/test/sdk/monitor/test_monitoring.py @@ -961,7 +961,7 @@ def test_set_agent_context_metrics_adds_aggregate_attributes(self, mock_trace): @patch('sdk.nexent.monitor.monitoring.trace') def test_record_final_context_evidence_adds_content_free_event(self, mock_trace): - from nexent.core.context_runtime.contracts import ContextEvidence + from nexent.core.agents.contracts.context import ContextEvidence with patch('sdk.nexent.monitor.monitoring.OPENTELEMETRY_AVAILABLE', True): manager = self._enabled_manager() mock_span = MagicMock() diff --git a/test/sdk/test_run_agent_cancellation_controls.py b/test/sdk/test_run_agent_cancellation_controls.py index 70852b0b50..b378c761b0 100644 --- a/test/sdk/test_run_agent_cancellation_controls.py +++ b/test/sdk/test_run_agent_cancellation_controls.py @@ -8,14 +8,15 @@ import pytest -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo +from nexent.core.agents.contracts.config import AgentConfig +from nexent.core.agents.contracts.run import AgentRunInfo from nexent.core.concurrency import LanePolicy, ThreadManager from nexent.core.utils.observer import MessageObserver @pytest.mark.asyncio async def test_stop_signal_closes_consumer_before_blocked_worker_finishes(monkeypatch): - module = importlib.import_module("nexent.core.agents.run_agent") + module = importlib.import_module("nexent.core.agents.orchestration.entry") entered, release = threading.Event(), threading.Event() def provider_worker(_run_info): From 8f242ddb71039698239f437a2e500e0e1fb352f5 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 30 Oct 2026 18:12:28 +0800 Subject: [PATCH 12/26] refactor(agents): share behavior prompts and delegation policy Centralize history, Skills, artifact and response rules across smolagents and Jiuwen. Share current-request delegation intent, authorized targets, successful dispatch state and bounded recovery while retaining Core-specific execution protocols. Add policy and protocol regressions and a two-Core file-assistant browser journey. Validation of the exact commit tree: 234 Python regressions and 14 controlled runtime tests. Previous delivery also passed 739 Python tests, 14 frontend tests and both real-model browser variants; retain the separately recorded global asset-gate limitation. --- .../core/agents/cores/jiuwen/adapter.py | 62 ++-- .../core/agents/cores/jiuwen/delegation.py | 40 +++ sdk/nexent/core/agents/cores/jiuwen/model.py | 24 +- .../core/agents/cores/jiuwen/protocol.py | 5 + .../core/agents/cores/smolagents/agent.py | 8 +- .../agents/cores/smolagents/core_agent.py | 61 +++- .../agents/cores/smolagents/delegation.py | 16 + .../core/agents/orchestration/delegation.py | 85 +++++ .../core/agents/output/python_actions.py | 58 ++++ sdk/nexent/core/agents/prompt/composer.py | 3 +- sdk/nexent/core/agents/prompt/policies.py | 64 ++++ sdk/nexent/core/agents/resources/subagents.py | 8 + .../core/prompts/en/agent/agent_manager.yaml | 21 -- .../core/prompts/en/agent/agent_worker.yaml | 21 -- .../core/prompts/en/agent/response_style.yaml | 21 ++ .../core/prompts/zh/agent/agent_manager.yaml | 21 -- .../core/prompts/zh/agent/agent_worker.yaml | 21 -- .../core/prompts/zh/agent/response_style.yaml | 21 ++ .../cases/JIUWEN-DELEGATION-D4-001/case.yaml | 59 ++++ .../JIUWEN-DELEGATION-D4-001/execution.yaml | 16 + .../cases/JIUWEN-DELEGATION-D4-001/fixture.py | 70 +++++ .../JIUWEN-DELEGATION-D4-001/test.spec.ts | 296 ++++++++++++++++++ .../SHARED-AGENT-POLICY-20261030.yaml | 45 +++ .../features/JIUWEN-RESOURCES/feature.yaml | 4 + .../agents/test_direct_prompt_resources.py | 7 +- .../sdk/core/agents/test_jiuwen_delegation.py | 278 ++++++++++++++++ ...st_nexent_agent_context_runtime_factory.py | 19 +- .../core/agents/test_shared_agent_policy.py | 242 ++++++++++++++ 28 files changed, 1465 insertions(+), 131 deletions(-) create mode 100644 sdk/nexent/core/agents/cores/jiuwen/delegation.py create mode 100644 sdk/nexent/core/agents/cores/smolagents/delegation.py create mode 100644 sdk/nexent/core/agents/orchestration/delegation.py create mode 100644 sdk/nexent/core/agents/output/python_actions.py create mode 100644 sdk/nexent/core/agents/prompt/policies.py create mode 100644 sdk/nexent/core/prompts/en/agent/response_style.yaml create mode 100644 sdk/nexent/core/prompts/zh/agent/response_style.yaml create mode 100644 test-e2e/cases/JIUWEN-DELEGATION-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-DELEGATION-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-DELEGATION-D4-001/fixture.py create mode 100644 test-e2e/cases/JIUWEN-DELEGATION-D4-001/test.spec.ts create mode 100644 test-e2e/changes/refactors/SHARED-AGENT-POLICY-20261030.yaml create mode 100644 test/sdk/core/agents/test_jiuwen_delegation.py create mode 100644 test/sdk/core/agents/test_shared_agent_policy.py diff --git a/sdk/nexent/core/agents/cores/jiuwen/adapter.py b/sdk/nexent/core/agents/cores/jiuwen/adapter.py index 6407509c39..f61e592c86 100644 --- a/sdk/nexent/core/agents/cores/jiuwen/adapter.py +++ b/sdk/nexent/core/agents/cores/jiuwen/adapter.py @@ -15,6 +15,7 @@ from nexent.core.agents.context.models import ContextItem, ContextItemType from nexent.core.agents.context.rendering import ContextItemRenderer from nexent.core.agents.contracts.adapter import AgentCoreAdapter, UnsupportedAgentCoreConfig +from nexent.core.agents.cores.jiuwen.delegation import DelegationPolicy from nexent.core.agents.cores.jiuwen.events import JiuwenEventAdapter from nexent.core.agents.resources.monitoring import _wrap_tool_with_monitoring from nexent.core.agents.resources.provider import AgentResources @@ -91,6 +92,11 @@ def __init__(self, run_info): self._closed = False self._tool_executions = [] self.text_protocol = False + config = getattr(run_info, "agent_config", None) + self.delegation = DelegationPolicy(getattr(run_info, "query", ""), [ + *(getattr(config, "worker_agents", None) or []), + *(getattr(config, "external_a2a_agents", None) or []), + ]) @classmethod def capabilities(cls): @@ -98,6 +104,8 @@ def capabilities(cls): def validate(self): config = self.run_info.agent_config + if self.delegation.pending and not self.delegation.names: + raise UnsupportedAgentCoreConfig("Explicit delegation requires an authorized collaborator") unsupported = [] if config.enable_planning: unsupported.append("enable_planning") @@ -323,6 +331,7 @@ async def execute(**arguments): if inspect.isawaitable(result): result = await result self._resource_result(result) + self.delegation.record_dispatch(name) return result return execute @@ -331,20 +340,11 @@ def _compile_context(self): from nexent.core.agents.orchestration.execution import _get_authorized_context_items, _get_authorized_history config = self.run_info.agent_config - instructions = [config.instructions or ""] context = [] for item in _get_authorized_context_items(self.run_info) or (): - if item.type == ContextItemType.SYSTEM: - # Platform code-action instructions cannot be reused by a native tool-calling core. - if item.id in {"system:duty", "system:constraint", "system:few_shots", - "system:knowledge_scope_policy", "system:memory_tool_policy", - "system:automation_tool_policy", "system:sandbox_workspace_guidance"} or ( - not item.id.startswith("system:") and item.metadata.get("authority", "agent") == "agent" - ): - instructions.append(item.content.get("text", "")) - elif item.type not in {ContextItemType.TOOL, ContextItemType.SKILL, - ContextItemType.WORKER_AGENT, ContextItemType.EXTERNAL_AGENT, - ContextItemType.CURRENT_TASK}: + if item.type not in {ContextItemType.SYSTEM, ContextItemType.TOOL, ContextItemType.SKILL, + ContextItemType.WORKER_AGENT, ContextItemType.EXTERNAL_AGENT, + ContextItemType.CURRENT_TASK}: context.append(ContextItem.from_input(item)) self.context_items = context self.prefix_messages = ContextItemRenderer().render(context) @@ -352,33 +352,16 @@ def _compile_context(self): {"role": history.role, "content": history.content} for history in _get_authorized_history(self.run_info) or () ) - prompt = "\n\n".join(text for text in instructions if text) or config.description - current_task_guidance = ( - "Process only the latest user request in this run. Earlier conversation messages, attachments and " - "results are historical reference, not pending tasks. Do not repeat earlier requests or tool calls " - "unless the latest user request explicitly asks you to. Reuse prior results when answering follow-up " - "questions; the presence of an earlier attachment does not require analyzing it again." - " Never copy internal history or attachment envelopes into your response. " - "Return only the user-facing answer as ordinary Markdown, without final_answer protocol tags." - ) - resource_guidance = ( - "Skills are authorized local resources, not S3 objects. Read a listed skill with read_skill_md " - "and its configuration with read_skill_config. Run only a script actually listed in that skill " - "using run_skill_script; never invent skill names, script paths or S3 paths. " - "download_from_s3 is only for actual authorized uploaded/generated file references, never for " - "skills or missing inputs. Do not retry a denied or missing file using guessed path variants. " - "For a new file request no download is needed. Follow the authorized skill instructions, then " - "create the requested file with controlled Python in the current outputs directory. " - "Use Python libraries and shell-free pip argv only when the configured sandbox permits it. " - "Upload the actual generated file with upload_to_s3 and use the returned file link. " - "Never claim a file exists or was uploaded without successful tool observations." + from nexent.core.agents.prompt.policies import business_system_instructions + + from nexent.core.agents.cores.jiuwen.protocol import NATIVE_PROTOCOL_INSTRUCTION + + shared = business_system_instructions( + config.instructions or config.description, _get_authorized_context_items(self.run_info) or (), + getattr(getattr(self.run_info, "observer", None), "lang", "en"), ) - if not self.text_protocol: - resource_guidance += ( - " Call nexent_execute_python with a complete Python code string to create files. " - "Code in ordinary prose or code tags is not executed in native mode." - ) - return prompt + "\n\n" + current_task_guidance + "\n\n" + resource_guidance + protocol = "" if self.text_protocol else NATIVE_PROTOCOL_INSTRUCTION + return "\n\n".join(text for text in (*shared, self.delegation.instruction, protocol) if text) def _create_model(self): from nexent.core.agents.cores.jiuwen.model import JiuwenServiceModel @@ -431,7 +414,8 @@ def validate_input(view): return JiuwenServiceModel(service, profile, self.scope, prefix=self.prefix_messages, run_id=self.owner_id, deadline=info.model_deadline, on_step=on_step, on_event=on_event, validate_input=validate_input, - on_error=self.events.record_error, on_recovery=self.events.recovery) + on_error=self.events.record_error, on_recovery=self.events.recovery, + delegation=self.delegation) def _bind_text_executor(self): from openjiuwen.core.foundation.tool import LocalFunction, ToolCard diff --git a/sdk/nexent/core/agents/cores/jiuwen/delegation.py b/sdk/nexent/core/agents/cores/jiuwen/delegation.py new file mode 100644 index 0000000000..b1763b81de --- /dev/null +++ b/sdk/nexent/core/agents/cores/jiuwen/delegation.py @@ -0,0 +1,40 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Map Jiuwen native and controlled-text proposals to the common delegation policy.""" + +import json + +from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, JiuwenProtocolError +from nexent.core.agents.orchestration.delegation import DelegationError +from nexent.core.agents.orchestration.delegation import DelegationPolicy as SharedDelegationPolicy +from nexent.core.agents.output.python_actions import is_delegation_action + + +class DelegationPolicy(SharedDelegationPolicy): + """Translate protocol envelopes and errors without redefining business policy.""" + + def constrain(self, view): + if not self.pending: + return view + definitions = [tool for tool in view.tool_definitions if tool.name in self.names] + if not definitions: + raise JiuwenProtocolError("JIUWEN_DELEGATION_UNAVAILABLE") + return view.model_copy(update={"tool_definitions": definitions}) + + def validate_calls(self, calls): + if not self.pending: + return + names = [] + for call in calls: + if call.name == EXECUTION_TOOL: + try: + code = json.loads(call.arguments)["code"] + except (ValueError, KeyError, TypeError): + code = None + names.append(next(iter(self.names), "") if is_delegation_action(code, self.names) else "") + else: + names.append(call.name) + try: + self.validate_targets(names) + except DelegationError as error: + raise JiuwenProtocolError("JIUWEN_DELEGATION_REQUIRED") from error diff --git a/sdk/nexent/core/agents/cores/jiuwen/model.py b/sdk/nexent/core/agents/cores/jiuwen/model.py index 3863118d39..df27c59740 100644 --- a/sdk/nexent/core/agents/cores/jiuwen/model.py +++ b/sdk/nexent/core/agents/cores/jiuwen/model.py @@ -42,7 +42,7 @@ class JiuwenServiceModel(Model): def __init__(self, service, profile, scope, *, prefix=(), run_id=None, deadline=None, on_step=None, on_event=None, validate_input=None, prepare_messages=None, - on_error=None, on_recovery=None): + on_error=None, on_recovery=None, delegation=None): # Model's public invoke/stream seam is used without its provider-client constructor. self.service = service self.profile = ResolvedModelProfile.model_validate(profile.model_dump()) @@ -64,6 +64,9 @@ def __init__(self, service, profile, scope, *, prefix=(), run_id=None, deadline= self.argument_corrections = 0 self.response_corrections = 0 self.logical_requests = 0 + self.delegation = delegation + self.delegation_corrections = 0 + self._delegation_failure = None def _check_cancelled(self): if self.scope.cancelled: @@ -74,6 +77,8 @@ def _check_cancelled(self): def _request(self, messages, tools, streaming, kwargs): self._check_cancelled() + if self._delegation_failure is not None: + raise ModelServiceError(self._delegation_failure) # Harness routing hints are accepted, but generation settings cannot override the snapshot. allowed = {"model", "temperature", "top_p", "max_tokens", "stop", "output_parser", "timeout"} if set(kwargs) - allowed: @@ -84,6 +89,8 @@ def _request(self, messages, tools, streaming, kwargs): if kwargs.get("model") not in (None, "", self.profile.identity.request_model_id): raise JiuwenProtocolError("JIUWEN_MODEL_IDENTITY_MISMATCH") view = resource_view(messages, tools, prefix=self.prefix, text_protocol=self.text_protocol) + if self.delegation is not None: + view = self.delegation.constrain(view) if self.validate_input is not None: self.validate_input(view) self.logical_requests += 1 @@ -139,6 +146,11 @@ def _completion(self, message, request, definitions): arguments=json.dumps({"code": block.code}), index=0)) if not calls and not content.strip(): self._fail(request, "MODEL_EMPTY_RESPONSE") + if self.delegation is not None: + try: + self.delegation.validate_calls(calls) + except JiuwenProtocolError as error: + self._fail(request, str(error)) self.last_message, self.last_usage = message, message.usage usage = message.usage native_usage = None @@ -231,6 +243,12 @@ def _correct(self, messages, error, step_corrections): return None self.response_corrections += 1 kind = "response" + elif category == "JIUWEN_DELEGATION_REQUIRED": + if self.delegation is None or not self.delegation.allow_correction(): + self._delegation_failure = error + return None + self.delegation_corrections += 1 + kind = "delegation" else: return None # Invalid calls never enter native history. Reuse only the authoritative, pre-step messages. @@ -239,11 +257,13 @@ def _correct(self, messages, error, step_corrections): "The previous model response was rejected before any action was dispatched: " + category + ". " "Regenerate this model step with a complete answer or a complete authorized call matching its schema. " "Use the existing tool observations; do not repeat completed actions." + + (" " + self.delegation.instruction if kind == "delegation" else "") )}) if self.on_recovery is not None: self.on_recovery({"kind": kind, "category": category, "request_id": str(error.request_id), "logical_request": self.logical_requests, "argument_corrections": self.argument_corrections, - "response_corrections": self.response_corrections}) + "response_corrections": self.response_corrections, + "delegation_corrections": self.delegation_corrections}) return current async def invoke(self, messages, *, tools=None, **kwargs): diff --git a/sdk/nexent/core/agents/cores/jiuwen/protocol.py b/sdk/nexent/core/agents/cores/jiuwen/protocol.py index 2a34c4f963..d0d111399e 100644 --- a/sdk/nexent/core/agents/cores/jiuwen/protocol.py +++ b/sdk/nexent/core/agents/cores/jiuwen/protocol.py @@ -9,6 +9,11 @@ EXECUTION_TOOL = "nexent_execute_block" NATIVE_EXECUTION_TOOL = "nexent_execute_python" +NATIVE_PROTOCOL_INSTRUCTION = ( + "Return the user-facing final answer as ordinary Markdown, without final_answer protocol tags. " + "Call nexent_execute_python with a complete Python code string to create files. " + "Code in ordinary prose or code tags is not executed in native mode." +) PROTOCOL_INSTRUCTION = ( "This outlet uses the controlled text execution protocol, not native function calls. " "Only complete ... blocks request Python execution with the listed authorized tools. " diff --git a/sdk/nexent/core/agents/cores/smolagents/agent.py b/sdk/nexent/core/agents/cores/smolagents/agent.py index 642c65448d..195e20b9d3 100644 --- a/sdk/nexent/core/agents/cores/smolagents/agent.py +++ b/sdk/nexent/core/agents/cores/smolagents/agent.py @@ -253,6 +253,8 @@ def create_single_agent( config=ctx_config, max_steps=agent_config.max_steps, ) + from nexent.core.agents.prompt.policies import with_common_behavior + context_items = apply_model_visible_tool_schemas_to_context_items( list(context_items_override) if context_items_override is not None @@ -295,7 +297,8 @@ def create_single_agent( ) context_runtime = ManagedContextRuntime( context_manager, - items=context_items, + items=(with_common_behavior(context_items, self.observer.lang) + if any(item.type == ContextItemType.SYSTEM for item in context_items) else context_items), ) # Build one code executor for this agent. Worker-agent orchestration @@ -534,6 +537,9 @@ def create_executor(): workspace_path=self.workspace_path, ) agent.stop_event = self.stop_event + agent._delegation_collaborators = [ + *agent_config.worker_agents, *(agent_config.external_a2a_agents or []), + ] # Wire plan tool deps if the plan tools are present in agent_config.tools. # CoreAgent already knows enable_planning (set above); use that to gate wiring. diff --git a/sdk/nexent/core/agents/cores/smolagents/core_agent.py b/sdk/nexent/core/agents/cores/smolagents/core_agent.py index 12dad124ce..e288900919 100644 --- a/sdk/nexent/core/agents/cores/smolagents/core_agent.py +++ b/sdk/nexent/core/agents/cores/smolagents/core_agent.py @@ -43,6 +43,7 @@ native_context_messages, native_context_rebuild, ) +from nexent.core.agents.cores.smolagents.delegation import DelegationPolicy from nexent.core.agents.cores.smolagents.output_protocol import ( ExecutableAction, ExplicitFinalAnswer, @@ -64,6 +65,7 @@ render_guardrail_refusal, render_tool_input_refusal, ) +from nexent.core.agents.orchestration.delegation import DelegationError from nexent.core.agents.prompt import AgentPromptComposer from nexent.core.agents.prompt.clarification import clarification_policy from nexent.core.agents.prompt.user_context import has_current_time_marker, render_user_context @@ -627,7 +629,9 @@ def __init__( self.plan_repo = PlanRepo(redis_client=redis_client) def initialize_system_prompt(self) -> str: - prompt = super().initialize_system_prompt() + from nexent.core.agents.prompt.policies import common_behavior_guidance + + prompt = super().initialize_system_prompt() + "\n\n" + common_behavior_guidance(self.lang) if self.clarification_tool_name: prompt += "\n\n" + clarification_policy( self.clarification_tool_name, @@ -1240,6 +1244,23 @@ def _thought_continuation_message(self) -> ChatMessage: f" Unfinished plan steps: {details}.") return ChatMessage(role=MessageRole.USER, content=[{"type": "text", "text": instruction}]) + def _validate_delegation_output(self, output): + """Check the complete proposal before any final-answer or execution branch.""" + policy = getattr(self, "_delegation", None) + if policy is None or not policy.pending: + return + try: + if self._use_structured_outputs_internally: + code = json.loads(output)["code"] + code = extract_code_from_text(code, self.code_block_tags) or code + else: + code = parse_code_blobs(output) + if re.search(r"", str(output), re.IGNORECASE): + code = None + except (ValueError, TypeError, KeyError, AttributeError): + code = None + policy.validate_code(code) + def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: """ Perform one step in the ReAct framework: the agent thinks, acts, and observes the result. @@ -1258,6 +1279,11 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: [ChatMessage(role=MessageRole.SYSTEM, content=[{"type": "text", "text": reminder}])] if strict_code_action else [] ) + delegation = getattr(self, "_delegation", None) + if delegation is not None and delegation.pending: + system_overlay.append(ChatMessage( + role=MessageRole.SYSTEM, content=[{"type": "text", "text": delegation.instruction}] + )) tail_overlay = [*getattr(self, "_protocol_repair_messages", [])] if strict_code_action: tail_overlay.append(ChatMessage( @@ -1425,6 +1451,8 @@ def rebuild_after_provider_overflow(): if self.stop_event.is_set(): raise RunTerminated() + self._validate_delegation_output(model_output) + # Parse using the configured closed output protocol. try: if legacy_code_action: @@ -1779,6 +1807,20 @@ def run(self, task: str, stream: bool = False, reset: bool = True, images: Optio agent.run("What is the result of 2 power 3.7384?") ``` """ + collaborators = getattr(self, "_delegation_collaborators", None) + if collaborators is None: + collaborators = list((getattr(self, "managed_agents", {}) or {}).values()) + self._delegation = DelegationPolicy( + task, collaborators, + enabled=(getattr(self, "output_protocol", "code_action") == "code_action" + and getattr(getattr(self, "observer", None), "enable_nl2a_wrapper", False) is not True), + ) + if self._delegation.pending and not self._delegation.names: + raise DelegationError("DELEGATION_UNAVAILABLE") + for tool in (getattr(self, "managed_agents", {}) or {}).values(): + bind = getattr(tool, "set_success_callback", None) + if callable(bind): + bind(self._delegation.record_dispatch) max_steps = max_steps or self.max_steps # Append current time before the workspace path in the user task rather than the # system prompt. This keeps the system prefix stable so prompt/KV caches @@ -2068,6 +2110,21 @@ def _run_stream( final_answer_source=terminal.source, ) + except DelegationError as error: + if self.stop_event.is_set(): + raise RunTerminated() from error + interrupted = True + self._resolve_deferred_model_attempt( + getattr(action_step, "model_output_message", None), accepted=False, + ) + if not self._delegation.allow_correction(): + raise ModelOutputProtocolExhaustedError("DELEGATION_REQUIRED") from error + self._protocol_repair_messages[:] = [ChatMessage( + role=MessageRole.USER, + content=[{"type": "text", "text": self._delegation.instruction + " " + _ACTION_FORMAT_REMINDER}], + )] + continue + except NonterminalThoughtTurn: if self.stop_event.is_set(): raise RunTerminated() @@ -2233,6 +2290,8 @@ def _run_stream( self.agent_name, ProcessType.MAX_STEPS_REACHED, max_steps_data) # _handle_max_steps_reached already yields the final step internally # and sets action_step.error, so don't yield again to avoid duplicate error + if getattr(self, "_delegation", None) is not None and self._delegation.pending: + raise ModelOutputProtocolExhaustedError("DELEGATION_REQUIRED") final_answer = self._handle_max_steps_reached(task) if not has_meaningful_visible_content(final_answer): final_answer = self._controlled_protocol_failure() diff --git a/sdk/nexent/core/agents/cores/smolagents/delegation.py b/sdk/nexent/core/agents/cores/smolagents/delegation.py new file mode 100644 index 0000000000..e4215d5d50 --- /dev/null +++ b/sdk/nexent/core/agents/cores/smolagents/delegation.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Validate CodeAgent actions against the common delegation state.""" + +from nexent.core.agents.orchestration.delegation import DelegationPolicy as SharedDelegationPolicy +from nexent.core.agents.output.python_actions import is_delegation_action + + +class DelegationPolicy(SharedDelegationPolicy): + """Project a Python action into the shared authorized-target contract.""" + + def validate_code(self, code): + if self.pending: + self.validate_targets( + [next(iter(self.names), "")] if is_delegation_action(code, self.names) else [] + ) diff --git a/sdk/nexent/core/agents/orchestration/delegation.py b/sdk/nexent/core/agents/orchestration/delegation.py new file mode 100644 index 0000000000..95a13cbcf3 --- /dev/null +++ b/sdk/nexent/core/agents/orchestration/delegation.py @@ -0,0 +1,85 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Run-local enforcement for explicit, current-request collaborator directives.""" + +import re + + +class DelegationError(ValueError): + """A requested authorized delegation has not been satisfied.""" + + +_QUOTED = re.compile(r'```[\s\S]*?```|"[^"\n]*"|\'[^\'\n]*\'|“[^”]*”|「[^」]*」') +_NEGATIVE = re.compile( + r"不要|不用|无需|不需要|不想|不希望|不允许|不准|不得|不必|不再|禁止|避免|别|不能" + r"|\b(?:do\s+not|don['’]t|never|without)\b", + re.I, +) +_QUESTION = re.compile(r"如何|怎么|为什么|解释|说明|\b(?:how|why|whether|explain)\b", re.I) +_CAPABILITY = re.compile(r"是否|能否|可否|能不能|可以|支持|\b(?:can\s+you|could\s+you|do\s+you)\b", re.I) +_HELP = re.compile(r"帮我|为我|\bplease\b", re.I) +_ACTION = re.compile(r"调用|使用|委派(?:给)?|交给|让|\b(?:call|invoke|use|ask|delegate)\b", re.I) +_GENERIC = re.compile(r"子智能体|子\s*agent|\b(?:sub[\s-]?agents?|workers?|a2a)\b", re.I) + + +class DelegationPolicy: + """Require actual authorized dispatch, never a proposed call or old history.""" + + def __init__(self, query, collaborators, *, enabled=True): + aliases = {} + for child in collaborators: + name = getattr(child, "invocation_name", None) or child.name + aliases[name] = {name.casefold(), child.name.casefold()} + if getattr(child, "display_name", None): + aliases[name].add(child.display_name.casefold()) + self.required = False + selected = set() + for clause in re.split(r"[,。!?!?;,\n]", _QUOTED.sub("", (query or "") if enabled else "")): + action = _ACTION.search(clause) + if action is None or _NEGATIVE.search(clause[: action.end()]) or _QUESTION.search(clause[: action.end()]): + continue + prefix = clause[: action.end()] + if _CAPABILITY.search(prefix) and not _HELP.search(prefix): + continue + target = clause[action.end() :].casefold() + named = {name for name, values in aliases.items() if any(value in target for value in values)} + if named or _GENERIC.search(target): + self.required = True + selected.update(named) + self.names = frozenset(selected or aliases) + self.dispatched = False + self.corrections = 0 + + @property + def pending(self): + return self.required and not self.dispatched + + @property + def instruction(self): + if not self.required: + return "" + return ( + "The latest request explicitly requires delegation before direct execution. " + "Call an authorized collaborator: " + ", ".join(sorted(self.names)) + ". " + "Do not create the file yourself or return a final answer before that call executes. " + "Pass the concrete task, deliverable and requested format, not an instruction to delegate again. " + "Reuse successfully uploaded worker artifacts without recreating or uploading them again. " + "Use the actual worker result and report failures truthfully." + ) + + def validate_targets(self, names): + """Reject empty proposals and unauthorized execution while delegation is pending.""" + if self.pending and (not names or any(name not in self.names for name in names)): + raise DelegationError("DELEGATION_REQUIRED") + + def allow_correction(self): + """Allow one logical regeneration per run; transport retries are separate.""" + if self.corrections >= 1: + return False + self.corrections += 1 + return True + + def record_dispatch(self, name): + """Release the gate only after the authorized entry returned successfully.""" + if name in self.names: + self.dispatched = True diff --git a/sdk/nexent/core/agents/output/python_actions.py b/sdk/nexent/core/agents/output/python_actions.py new file mode 100644 index 0000000000..652b6c6a4f --- /dev/null +++ b/sdk/nexent/core/agents/output/python_actions.py @@ -0,0 +1,58 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Restricted Python call grammar shared by code-action execution protocols.""" + +import ast + + +def is_delegation_action(code, names): + """Accept direct collaborator calls and optional result assignment/printing only.""" + try: + tree = ast.parse(code) + except (SyntaxError, ValueError, KeyError, TypeError): + return False + # Before delegation, a text block is only a direct worker call, optional + # assignment and print. Imports, attributes and indirect calls cannot act. + allowed = ( + ast.Module, + ast.Expr, + ast.Assign, + ast.Call, + ast.Name, + ast.Load, + ast.Store, + ast.keyword, + ast.Constant, + ast.Dict, + ast.List, + ast.Tuple, + ) + assignments = [node for node in tree.body if isinstance(node, ast.Assign)] + variables = set() + for assignment in assignments: + if ( + not isinstance(assignment.value, ast.Call) + or not isinstance(assignment.value.func, ast.Name) + or assignment.value.func.id not in names + or any( + not isinstance(target, ast.Name) or target.id in names | {"print"} + for target in assignment.targets + ) + ): + return False + variables.update(target.id for target in assignment.targets) + worker_calls = 0 + for node in ast.walk(tree): + if not isinstance(node, allowed): + return False + if isinstance(node, ast.Name) and node.id not in names | {"print"} | variables: + return False + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Name) or node.func.id not in names | {"print"}: + return False + worker_calls += node.func.id in names + if any(keyword.arg is None for keyword in node.keywords): + return False + if isinstance(node, ast.Assign) and any(not isinstance(target, ast.Name) for target in node.targets): + return False + return worker_calls > 0 diff --git a/sdk/nexent/core/agents/prompt/composer.py b/sdk/nexent/core/agents/prompt/composer.py index c53c42c1db..d5cd2d3b9a 100644 --- a/sdk/nexent/core/agents/prompt/composer.py +++ b/sdk/nexent/core/agents/prompt/composer.py @@ -11,6 +11,7 @@ from ..context.models import ContextItemInput, ContextItemType from .bundle import AgentPromptBundle, _thaw from .knowledge import render_knowledge_base_summaries, render_knowledge_scope_resources +from .policies import with_common_behavior _RENDERER = Environment(undefined=StrictUndefined, autoescape=False) @@ -343,7 +344,7 @@ def add_system( + composer.render_system_section("code_norms") ) add_system("code_norms", code_norms, 20, 31, "platform") - return inputs + return with_common_behavior(inputs, language) def compatibility_templates(self) -> dict[str, Any]: """Provide mutable legacy SDK templates without mutating the bundle.""" diff --git a/sdk/nexent/core/agents/prompt/policies.py b/sdk/nexent/core/agents/prompt/policies.py new file mode 100644 index 0000000000..10cf0911fd --- /dev/null +++ b/sdk/nexent/core/agents/prompt/policies.py @@ -0,0 +1,64 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Core-neutral behavior policies and business context selection.""" + +from nexent.core.agents.context.models import ContextItemInput, ContextItemType +from nexent.core.prompts import load_prompt + + +CURRENT_TASK_GUIDANCE = ( + "Process only the latest user request in this run. Earlier conversation messages, attachments and " + "results are historical reference, not pending tasks. Do not repeat earlier requests or tool calls " + "unless the latest user request explicitly asks you to. Reuse prior results when answering follow-up " + "questions; the presence of an earlier attachment does not require analyzing it again." + " Never copy internal history or attachment envelopes into your response. " + "Keep the final answer limited to user-facing content; internal records are reference only." +) + +RESOURCE_GUIDANCE = ( + "Honor an explicit latest-request delegation through an authorized collaborator before direct execution. " + "The file-creation instructions below apply only when direct execution is appropriate, " + "not as a substitute for requested delegation. " + "Skills are authorized local resources, not S3 objects. Read a listed skill with read_skill_md " + "and its configuration with read_skill_config. Run only a script actually listed in that skill " + "using run_skill_script; never invent skill names, script paths or S3 paths. " + "download_from_s3 is only for actual authorized uploaded/generated file references, never for " + "skills or missing inputs. Do not retry a denied or missing file using guessed path variants. " + "For a new file request no download is needed. Follow the authorized skill instructions, then " + "create the requested file with controlled Python in the current outputs directory. " + "Use Python libraries and shell-free pip argv only when the configured sandbox permits it. " + "Upload the actual generated file with upload_to_s3 and use the returned file link. " + "Never claim a file exists or was uploaded without successful tool observations." +) + +COMMON_BEHAVIOR_GUIDANCE = CURRENT_TASK_GUIDANCE + "\n\n" + RESOURCE_GUIDANCE +CORE_PROTOCOL_SYSTEM_IDS = frozenset({ + "system:execution_flow", "system:code_norms", "system:restricted_python_execution", + "system:final_answer_guidance", "system:clarification_protocol", +}) + + +def common_behavior_guidance(language="en"): + """Render the same behavior and user-facing result rules for every Core.""" + language = language if isinstance(language, str) and language in {"zh", "en"} else "en" + return COMMON_BEHAVIOR_GUIDANCE + "\n\n" + load_prompt(language, "agent/response_style")["policy"] + + +def with_common_behavior(items, language="en"): + """Insert one authoritative shared behavior item without mutating saved inputs.""" + return [ + *(item for item in items if item.id != "system:common_behavior"), + ContextItemInput( + id="system:common_behavior", type=ContextItemType.SYSTEM, + content={"text": common_behavior_guidance(language)}, source=("platform:common_behavior",), priority=99, + metadata={"authority": "platform", "layout_order": 36}, + ), + ] + + +def business_system_instructions(instructions, items, language="en"): + """Project shared authorized business/system sections without executable Core syntax.""" + return [instructions or "", *( + item.content.get("text", "") for item in with_common_behavior(items, language) + if item.type == ContextItemType.SYSTEM and item.id not in CORE_PROTOCOL_SYSTEM_IDS + )] diff --git a/sdk/nexent/core/agents/resources/subagents.py b/sdk/nexent/core/agents/resources/subagents.py index 170e40dcc4..7d3b7874f1 100644 --- a/sdk/nexent/core/agents/resources/subagents.py +++ b/sdk/nexent/core/agents/resources/subagents.py @@ -85,6 +85,7 @@ class SubAgentToolWrapper: "_runtime_identity", "_task_extractor", "_invocation_factory", + "_on_success", }) def __init__( @@ -102,6 +103,7 @@ def __init__( # ``__setattr__`` below (which forwards everything to the inner # agent) does not interfere with our own construction. object.__setattr__(self, "_inner", inner_agent) + object.__setattr__(self, "_on_success", None) object.__setattr__(self, "_invocation_factory", invocation_factory) object.__setattr__(self, "_observer", observer) object.__setattr__(self, "_agent_id", agent_id) @@ -154,6 +156,10 @@ def __setattr__(self, item: str, value: Any) -> None: # so subsequent reads still work. object.__setattr__(self, item, value) + def set_success_callback(self, callback): + """Bind a parent-run completion hook without changing child configuration.""" + self._on_success = callback + # --- invocation -------------------------------------------------------- def __call__(self, *args: Any, **kwargs: Any) -> Any: @@ -219,6 +225,8 @@ def _invoke(self, target: Callable, args: tuple, kwargs: dict, *, forward_call: target = getattr(agent, "forward", agent) if forward_call else agent result = target(*args, **kwargs) manager.set_openinference_output(result) + if self._on_success is not None: + self._on_success(self.name) return result finally: end_kwargs = dict(self._runtime_identity) diff --git a/sdk/nexent/core/prompts/en/agent/agent_manager.yaml b/sdk/nexent/core/prompts/en/agent/agent_manager.yaml index 7fcfbba658..b484440ab5 100644 --- a/sdk/nexent/core/prompts/en/agent/agent_manager.yaml +++ b/sdk/nexent/core/prompts/en/agent/agent_manager.yaml @@ -43,27 +43,6 @@ system_sections: ### Final Answer Requirements Executable actions and final answers must be separate responses. After emitting code, wait for its observation. Never combine a code action and a envelope, print a redundant completion action, or claim file generation/upload succeeded before the tool observation confirms it. After thinking, when you are ready to answer, return exactly one `...` envelope with the complete user-facing answer inside it. Do not wrap the envelope in ``, put content outside it, or perform another action afterward. - - Follow the user's requested response format strictly; do not add headings, paragraphs, or fields the user did not request. - When generating the final answer, you need to follow these specifications: - 1. **Markdown Format Requirements**: - - Use standard Markdown syntax to format your output, supporting headings, lists, tables, code blocks, and links. - - Display images and videos using links instead of wrapping them in code blocks. Use `[link text](URL)` for links, `![alt text](image URL)` for images, and `` for videos. - - For uploaded or generated Nexent files, use the permanent S3 URL (`s3://bucket/object-path`) returned by the tool as the Markdown URL. - - Never expose a presigned URL, a signed MinIO URL, or a local file path in the final answer. - - Use a single blank line between paragraphs, avoid multiple consecutive blank lines - - Mathematical formulas use standard Markdown format: inline formulas use $formula$, block formulas use $$formula$$ - - 2. **Retrieval Reference Marks** (for final answers only): - - A single reference mark uses `[[lowercase-letter+number]]`, such as `[[a1]]` or `[[b2]]`; multiple marks may be consecutive, such as `[[a1]][[b2]]` - - Copy each mark from the retrieval result's `reference_mark` field - - When the final answer uses a fact from retrieval, place the matching mark or marks at the end of that sentence - - A mark applies only to the nearest preceding sentence. Mark facts from different sources sentence by sentence instead of sharing marks across a paragraph - - In tables, place marks in the cells containing the supported facts - - Unless the user explicitly requests it, do not add hyperlinks or a bibliography for retrieval citations - - 3. **Other Format Requirements**: - - Code in code blocks should maintain original format, do not add extra escape characters self_verification_guidance: |- ### Self-verification - After critical events (tool calls, retrieval results, code execution, agent handoffs, and final-answer preparation), the system may run explicit verification. diff --git a/sdk/nexent/core/prompts/en/agent/agent_worker.yaml b/sdk/nexent/core/prompts/en/agent/agent_worker.yaml index 5a22d76740..b9dbd4c467 100644 --- a/sdk/nexent/core/prompts/en/agent/agent_worker.yaml +++ b/sdk/nexent/core/prompts/en/agent/agent_worker.yaml @@ -43,27 +43,6 @@ system_sections: ### Final Answer Requirements Executable actions and final answers must be separate responses. After emitting code, wait for its observation. Never combine a code action and a envelope, print a redundant completion action, or claim file generation/upload succeeded before the tool observation confirms it. After thinking, when you are ready to answer, return exactly one `...` envelope with the complete user-facing answer inside it. Do not wrap the envelope in ``, put content outside it, or perform another action afterward. - - Follow the user's requested response format strictly; do not add headings, paragraphs, or fields the user did not request. - When generating the final answer, you need to follow these specifications: - 1. **Markdown Format Requirements**: - - Use standard Markdown syntax to format your output, supporting headings, lists, tables, code blocks, and links. - - Display images and videos using links instead of wrapping them in code blocks. Use `[link text](URL)` for links, `![alt text](image URL)` for images, and `` for videos. - - For uploaded or generated Nexent files, use the permanent S3 URL (`s3://bucket/object-path`) returned by the tool as the Markdown URL. - - Never expose a presigned URL, a signed MinIO URL, or a local file path in the final answer. - - Use a single blank line between paragraphs, avoid multiple consecutive blank lines - - Mathematical formulas use standard Markdown format: inline formulas use $formula$, block formulas use $$formula$$ - - 2. **Retrieval Reference Marks** (for final answers only): - - A single reference mark uses `[[lowercase-letter+number]]`, such as `[[a1]]` or `[[b2]]`; multiple marks may be consecutive, such as `[[a1]][[b2]]` - - Copy each mark from the retrieval result's `reference_mark` field - - When the final answer uses a fact from retrieval, place the matching mark or marks at the end of that sentence - - A mark applies only to the nearest preceding sentence. Mark facts from different sources sentence by sentence instead of sharing marks across a paragraph - - In tables, place marks in the cells containing the supported facts - - Unless the user explicitly requests it, do not add hyperlinks or a bibliography for retrieval citations - - 3. **Other Format Requirements**: - - Code in code blocks should maintain original format, do not add extra escape characters self_verification_guidance: |- ### Self-verification - After critical events (tool calls, retrieval results, code execution, agent handoffs, and final-answer preparation), the system may run explicit verification. diff --git a/sdk/nexent/core/prompts/en/agent/response_style.yaml b/sdk/nexent/core/prompts/en/agent/response_style.yaml new file mode 100644 index 0000000000..d113fab75e --- /dev/null +++ b/sdk/nexent/core/prompts/en/agent/response_style.yaml @@ -0,0 +1,21 @@ +policy: |- + Follow the user's requested response format strictly; do not add headings, paragraphs, or fields the user did not request. + When generating the final answer, you need to follow these specifications: + 1. **Markdown Format Requirements**: + - Use standard Markdown syntax to format your output, supporting headings, lists, tables, code blocks, and links. + - Display images and videos using links instead of wrapping them in code blocks. Use `[link text](URL)` for links, `![alt text](image URL)` for images, and `` for videos. + - For uploaded or generated Nexent files, use the permanent S3 URL (`s3://bucket/object-path`) returned by the tool as the Markdown URL. + - Never expose a presigned URL, a signed MinIO URL, or a local file path in the final answer. + - Use a single blank line between paragraphs, avoid multiple consecutive blank lines + - Mathematical formulas use standard Markdown format: inline formulas use $formula$, block formulas use $$formula$$ + + 2. **Retrieval Reference Marks** (for final answers only): + - A single reference mark uses `[[lowercase-letter+number]]`, such as `[[a1]]` or `[[b2]]`; multiple marks may be consecutive, such as `[[a1]][[b2]]` + - Copy each mark from the retrieval result's `reference_mark` field + - When the final answer uses a fact from retrieval, place the matching mark or marks at the end of that sentence + - A mark applies only to the nearest preceding sentence. Mark facts from different sources sentence by sentence instead of sharing marks across a paragraph + - In tables, place marks in the cells containing the supported facts + - Unless the user explicitly requests it, do not add hyperlinks or a bibliography for retrieval citations + + 3. **Other Format Requirements**: + - Code in code blocks should maintain original format, do not add extra escape characters diff --git a/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml b/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml index 7880b377ab..86dcab90f2 100644 --- a/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml +++ b/sdk/nexent/core/prompts/zh/agent/agent_manager.yaml @@ -44,27 +44,6 @@ system_sections: ### 最终回答要求 可执行动作和最终回答必须分开输出。输出代码后等待执行观察结果。禁止在同一响应中同时输出代码动作和 ,禁止为宣布完成再执行多余的 print,禁止在工具观察结果确认前宣称文件已生成或上传。 思考结束后,当你认为可以回答用户问题,必须输出唯一一对 `...`,并将完整的用户可见回答放在标签内。不得在标签外输出内容,也不得继续执行动作。 - - 用户明确指定响应格式时,应严格按该格式回答;不擅自增加用户未要求的标题、段落或字段。 - 生成最终回答时,你需要遵循以下规范: - 1. Markdown格式要求: - - 使用标准Markdown语法格式化输出,支持标题、列表、表格、代码块、链接等 - - 展示图片和视频使用链接方式,不需要外套代码块,格式:[链接文本](URL),图片格式:![alt文本](图片URL),视频格式: - - 对已上传或生成的 Nexent 文件,必须使用工具结果中的永久 S3 URL(`s3://存储桶/对象路径`)作为 Markdown URL - - 禁止在最终回答中输出 presigned_url、带签名查询参数的 MinIO URL 或本地文件路径 - - 段落之间使用单个空行分隔,避免多个连续空行 - - 数学公式使用标准Markdown格式:行内公式用 $公式$,块级公式用 $$公式$$ - - 2. 检索工具引用标记规范(仅用于最终回答): - - 单个引用标记格式为:`[[小写字母+数字]]`,如`[[a1]]`、`[[b2]]`;连续的多个引用标记可在同一对反引号中声明,如`[[a1]][[b2]]` - - 引用标记的字母和数字必须与检索工具的检索结果一一对应;请直接使用结果中 `reference_mark` 字段提供的标记 - - 只要最终回答使用了任一检索结果中的事实,就必须在该事实所在的 **句末** 添加对应的一个或多个引用标记 - - 一个引用标记只对应它前方最近的一句话。不同来源支撑的内容必须分别在句末添加引用标记,不得在整段回答末统一共用所有引用标记 - - 表格中的引用标记必须写在对应事实所在的单元格末尾;不要只在表格标题、说明或表格下方统一引用 - - 除非用户明确要求,否则不要擅自使用超链接、参考文献来表达检索工具的引用内容 - - 3. 其他格式要求: - - 代码块中的代码应保持原始格式,不要添加额外的转义字符 self_verification_guidance: |- ### 自验证 - 关键事件(工具调用、检索结果、代码执行、子智能体返回、准备最终回答)后,系统会进行显式自验证。 diff --git a/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml b/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml index 025b8750bf..b4bf0d24ce 100644 --- a/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml +++ b/sdk/nexent/core/prompts/zh/agent/agent_worker.yaml @@ -43,27 +43,6 @@ system_sections: ### 最终回答要求 可执行动作和最终回答必须分开输出。输出代码后等待执行观察结果。禁止在同一响应中同时输出代码动作和 ,禁止为宣布完成再执行多余的 print,禁止在工具观察结果确认前宣称文件已生成或上传。 思考结束后,当你认为可以回答用户问题,必须输出唯一一对 `...`,将完整的用户可见回答放在标签内。不得在标签外输出内容,也不得继续执行动作。 - - 用户明确指定响应格式时,应严格按该格式回答;不擅自增加用户未要求的标题、段落或字段。 - 生成最终回答时,你需要遵循以下规范: - 1. Markdown格式要求: - - 使用标准Markdown语法格式化输出,支持标题、列表、表格、代码块、链接等 - - 展示图片和视频使用链接方式,不需要外套代码块,格式:[链接文本](URL),图片格式:![alt文本](图片URL),视频格式: - - 对已上传或生成的 Nexent 文件,必须使用工具结果中的永久 S3 URL(`s3://存储桶/对象路径`)作为 Markdown URL - - 禁止在最终回答中输出 presigned_url、带签名查询参数的 MinIO URL 或本地文件路径 - - 段落之间使用单个空行分隔,避免多个连续空行 - - 数学公式使用标准Markdown格式:行内公式用 $公式$,块级公式用 $$公式$$ - - 2. 检索工具引用标记规范(仅用于最终回答): - - 单个引用标记格式为:`[[小写字母+数字]]`,如`[[a1]]`、`[[b2]]`;连续的多个引用标记可在同一对反引号中声明,如`[[a1]][[b2]]` - - 引用标记的字母和数字必须与检索工具的检索结果一一对应;请直接使用结果中 `reference_mark` 字段提供的标记 - - 只要最终回答使用了任一检索结果中的事实,就必须在该事实所在的 **句末** 添加对应的一个或多个引用标记 - - 一个引用标记只对应它前方最近的一句话。不同来源支撑的内容必须分别在句末添加引用标记,不得在整段回答末统一共用所有引用标记 - - 表格中的引用标记必须写在对应事实所在的单元格末尾;不要只在表格标题、说明或表格下方统一引用 - - 除非用户明确要求,否则不要擅自使用超链接、参考文献来表达检索工具的引用内容 - - 3. 其他格式要求: - - 代码块中的代码应保持原始格式,不要添加额外的转义字符 self_verification_guidance: |- ### 自验证 - 关键事件(工具调用、检索结果、代码执行、子智能体返回、准备最终回答)后,系统会进行显式自验证。 diff --git a/sdk/nexent/core/prompts/zh/agent/response_style.yaml b/sdk/nexent/core/prompts/zh/agent/response_style.yaml new file mode 100644 index 0000000000..1da4461652 --- /dev/null +++ b/sdk/nexent/core/prompts/zh/agent/response_style.yaml @@ -0,0 +1,21 @@ +policy: |- + 用户明确指定响应格式时,应严格按该格式回答;不擅自增加用户未要求的标题、段落或字段。 + 生成最终回答时,你需要遵循以下规范: + 1. Markdown格式要求: + - 使用标准Markdown语法格式化输出,支持标题、列表、表格、代码块、链接等 + - 展示图片和视频使用链接方式,不需要外套代码块,格式:[链接文本](URL),图片格式:![alt文本](图片URL),视频格式: + - 对已上传或生成的 Nexent 文件,必须使用工具结果中的永久 S3 URL(`s3://存储桶/对象路径`)作为 Markdown URL + - 禁止在最终回答中输出 presigned_url、带签名查询参数的 MinIO URL 或本地文件路径 + - 段落之间使用单个空行分隔,避免多个连续空行 + - 数学公式使用标准Markdown格式:行内公式用 $公式$,块级公式用 $$公式$$ + + 2. 检索工具引用标记规范(仅用于最终回答): + - 单个引用标记格式为:`[[小写字母+数字]]`,如`[[a1]]`、`[[b2]]`;连续的多个引用标记可在同一对反引号中声明,如`[[a1]][[b2]]` + - 引用标记的字母和数字必须与检索工具的检索结果一一对应;请直接使用结果中 `reference_mark` 字段提供的标记 + - 只要最终回答使用了任一检索结果中的事实,就必须在该事实所在的 **句末** 添加对应的一个或多个引用标记 + - 一个引用标记只对应它前方最近的一句话。不同来源支撑的内容必须分别在句末添加引用标记,不得在整段回答末统一共用所有引用标记 + - 表格中的引用标记必须写在对应事实所在的单元格末尾;不要只在表格标题、说明或表格下方统一引用 + - 除非用户明确要求,否则不要擅自使用超链接、参考文献来表达检索工具的引用内容 + + 3. 其他格式要求: + - 代码块中的代码应保持原始格式,不要添加额外的转义字符 diff --git a/test-e2e/cases/JIUWEN-DELEGATION-D4-001/case.yaml b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/case.yaml new file mode 100644 index 0000000000..3af26e9c05 --- /dev/null +++ b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/case.yaml @@ -0,0 +1,59 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: JIUWEN-DELEGATION-D4-001 + feature_id: JIUWEN-RESOURCES + business_rule_ids: [JIUWEN-RESOURCES-RULE-005] + title: Generic explicit delegation generates a real child file in live and restored UI + type: E2E + priority: P0 + status: active + automation: automated + objective: Prove both Cores share generic delegation behavior and render configured child execution and saved results. + preconditions: + - Authorized saved file assistant with a file-generation worker, available real model and deployed common Web/runtime. + test_data: + profile: real_smoke + locale: zh + core_variants: [smolagents, jiuwen] + variant_selection: NEXENT_DELEGATION_CORE; both variant runs are required for shared-policy acceptance + steps: + - order: 1 + action: For each Core smolagents and Jiuwen, clone the authorized assistant tree and send the generic request + to call a sub-agent and generate a simple docx with a fresh marker. + expected: A nested worker card appears while streaming; real child start/end and child-owned + execution generate and upload a downloadable docx containing the marker. + - order: 2 + action: Open canonical saved history and refresh the page, then expand the same child card. + expected: The same child result and file remain visible, persisted message identities are + unchanged and history navigation starts no execution. + - order: 3 + action: Ask an ordinary follow-up about the saved result and reload again. + expected: The answer recalls the saved fact without a child call or file tool replay; + only owned fixtures are cleaned and source configuration/history remain unchanged. + expected_results: + - Actual authorized delegation, downloaded docx verification and live common nested UI. + - Reload restores nested ownership and results without another Agent execution. + - Follow-up uses historical results without replay and isolated cleanup preserves source data. + forbidden_side_effects: + - No fabricated child card or file success, root direct-generation bypass, model-result substitution or credential output. + - No historical replay, row mutation, source assistant change or deletion outside owned fixtures. + actor: Authorized Agent user + journey: + - Delegate a real file task, observe the child live, reload and continue from saved results. + final_business_result: + - Explicit delegation and persisted common presentation agree with actual child execution. + failure_evidence: + - Safe SSE summaries, DOM assertions, database identities, downloaded document check and screenshots. + acceptance: + - id: AC-01 + description: Actual child file creation and live nested presentation. + steps: [1] + expected_results: [1] + forbidden_side_effects: [1] + - id: AC-02 + description: Saved nested results and ordinary follow-up preserve ownership without replay. + steps: [2, 3] + expected_results: [2, 3] + forbidden_side_effects: [2] diff --git a/test-e2e/cases/JIUWEN-DELEGATION-D4-001/execution.yaml b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/execution.yaml new file mode 100644 index 0000000000..979fdb6f0d --- /dev/null +++ b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: JIUWEN-DELEGATION-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: JIUWEN-DELEGATION-D4-001 + profiles: [real_smoke] +timeout_seconds: 900 +acceptance_bindings: + AC-01: [JIUWEN-DELEGATION-D4-001] + AC-02: [JIUWEN-DELEGATION-D4-001] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/JIUWEN-DELEGATION-D4-001/fixture.py b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/fixture.py new file mode 100644 index 0000000000..ef9e283176 --- /dev/null +++ b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/fixture.py @@ -0,0 +1,70 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Inspect owned nested execution while reusing the source-preserving file fixture.""" + +import importlib.util +import json +import sys +from pathlib import Path + +import httpx +from management.db.client import get_db_session +from management.db.models import ConversationMessage, ConversationMessageUnit, ConversationRecord + + +mode, filename, helper_path = sys.argv[1:4] +if mode == "select-core": + state = json.loads(Path(filename).read_text()) + core = sys.argv[4] + assert core in {"smolagents", "jiuwen"} + assert state["agent_id"] != state["source"] and state["agent_id"] in state["owned_agents"] + with httpx.Client( + base_url="http://nexent-config:5010", + trust_env=False, + timeout=90, + headers={"Authorization": "Bearer " + state["access_token"]}, + ) as client: + response = client.post( + "/agent/update", json={"agent_id": state["agent_id"], **state["copied_fields"], "agent_core": core} + ) + assert response.status_code == 200 + response = client.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Delegation validation"}) + assert response.status_code == 200 + print(json.dumps({"configured_core": core})) +elif mode == "nested": + state = json.loads(Path(filename).read_text()) + conversation = int(sys.argv[4]) + with get_db_session() as session: + owner = session.query(ConversationRecord).filter_by(conversation_id=conversation).one() + assert owner.agent_id == state["agent_id"] and owner.created_by == state["user"] + messages = ( + session.query(ConversationMessage) + .filter_by(conversation_id=conversation) + .order_by(ConversationMessage.message_id) + .all() + ) + units = ( + session.query(ConversationMessageUnit) + .filter_by(conversation_id=conversation) + .order_by(ConversationMessageUnit.unit_index) + .all() + ) + print( + json.dumps( + { + "messages": [ + {"id": m.message_id, "index": m.message_index, "status": m.status, "role": m.message_role} + for m in messages + ], + "starts": sum(unit.unit_type == "subagent_start" for unit in units), + "ends": sum(unit.unit_type == "subagent_end" for unit in units), + "nested_units": sum(bool(unit.invocation_id) for unit in units), + } + ) + ) +else: + spec = importlib.util.spec_from_file_location("owned_file_fixture", helper_path) + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + sys.argv = [sys.argv[0], mode, filename, *sys.argv[4:]] + helper.main() diff --git a/test-e2e/cases/JIUWEN-DELEGATION-D4-001/test.spec.ts b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/test.spec.ts new file mode 100644 index 0000000000..ce961b4dfa --- /dev/null +++ b/test-e2e/cases/JIUWEN-DELEGATION-D4-001/test.spec.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { journey } from "../../infra/automation/d4/runner/journey"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +journey("JIUWEN-DELEGATION-D4-001", async ({ page, contract, expect }) => { + const config = JSON.parse( + readFileSync(process.env.NEXENT_REAL_UI_CONFIG!, "utf8"), + ); + const base = process.env.NEXENT_BASE_URL || "http://127.0.0.1:3000"; + const container = config.container || "nexent-runtime"; + const core = process.env.NEXENT_DELEGATION_CORE || "jiuwen"; + expect(["jiuwen", "smolagents"]).toContain(core); + const target = + "/tmp/nexent_delegate_" + randomUUID().replaceAll("-", "").slice(0, 12); + const privateState = join(contract.caseDir, "private-state.json"); + const marker = "DELEGATION_" + randomUUID().slice(0, 8); + const outcomes: Record[] = [{ configured_core: core }]; + const save = () => + writeFileSync( + join(contract.caseDir, "delegation-outcomes.json"), + JSON.stringify(outcomes, null, 2), + ); + const docker = (...args: string[]) => + execFileSync("docker", args, { + encoding: "utf8", + timeout: 120000, + stdio: ["ignore", "pipe", "pipe"], + }); + const fixture = (mode: string, ...args: unknown[]) => + JSON.parse( + docker( + "exec", + "-e", + "PYTHONPATH=/opt/backend", + "-w", + "/opt/backend", + container, + "/opt/backend/.venv/bin/python", + target + ".py", + mode, + target + ".json", + target + "_helper.py", + ...args.map(String), + ) + .trim() + .split(/\r?\n/) + .at(-1)!, + ); + for (const [source, suffix] of [ + ["JIUWEN-DELEGATION-D4-001/fixture.py", ".py"], + ["JIUWEN-REAL-UI-D4-001/fixture.py", "_helper.py"], + ]) { + docker( + "cp", + join(process.env.NEXENT_REPO!, "test-e2e/cases", source), + container + ":" + target + suffix, + ); + } + let state: any; + let cid = 0; + let runs = 0; + let original: unknown[] = []; + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.name)); + page.on("request", (request) => { + if ( + request.method() === "POST" && + new URL(request.url()).pathname === "/api/agent/run" + ) + runs++; + }); + contract.deferCleanup(async () => { + try { + const cleaned = fixture("cleanup"); + outcomes.push(cleaned); + save(); + expect(cleaned.cleanup).toBe(true); + expect(cleaned.source_unchanged).toBe(true); + } finally { + if (existsSync(privateState)) unlinkSync(privateState); + docker( + "exec", + container, + "/opt/backend/.venv/bin/python", + "-c", + `from pathlib import Path;[Path('${target}'+s).unlink(missing_ok=True) for s in ['.py','_helper.py']]`, + ); + } + }); + await contract.precondition( + contract.expectedIds("preconditions")[0], + async () => { + expect( + fixture( + "setup", + config.source_agent_id, + config.user_id, + config.source_message_id, + ).setup, + ).toBe(true); + expect(fixture("select-core", core).configured_core).toBe(core); + docker("cp", container + ":" + target + ".json", privateState); + state = JSON.parse(readFileSync(privateState, "utf8")); + await page.context().addCookies([ + { + name: "nexent_access_token", + value: state.access_token, + url: base, + httpOnly: true, + sameSite: "Lax", + expires: state.expires_at, + }, + { + name: "nexent_token_expires_at", + value: String(state.expires_at), + url: base, + sameSite: "Lax", + expires: state.expires_at, + }, + ]); + }, + ); + const answers = () => + page.locator('[data-slot="aui_assistant-message-root"]'); + const child = () => + answers().first().locator("[data-subagent-run-id]").first(); + const openChildGroup = async () => { + const group = answers() + .first() + .getByRole("button", { name: "1 subagent call", exact: true }); + await expect(group).toBeVisible(); + if ((await group.getAttribute("data-state")) === "closed") + await group.click(); + }; + const send = async (query: string) => { + await page.getByRole("textbox").last().fill(query); + const wire = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/agent/run", + { timeout: 30000 }, + ); + await page.getByRole("button", { name: "发送", exact: true }).click(); + const response = await wire; + expect(response.status()).toBe(200); + cid = + response.request().postDataJSON().conversation_id || + Number(response.headers()["conversation_id"]); + expect(cid).toBeGreaterThan(0); + return response; + }; + const events = async (response: Awaited>) => + (await response.body()) + .toString("utf8") + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => JSON.parse(line.slice(5).trim())); + const steps = contract.expectedIds("steps"); + await contract.step(steps[0], async () => { + await page.goto(`${base}/zh/newchat?agent_id=${state.agent_id}`); + const response = await send( + `调用子智能体,帮我生成一个docx文件,简单写点内容就行。文档正文必须包含验证标记 ${marker},最后回答也请包含此标记和下载链接。`, + ); + await expect(child()).toBeVisible({ timeout: 180000 }); + await expect(child().getByText("Running", { exact: true })).toBeVisible(); + await page.screenshot({ path: join(contract.caseDir, "child-live.png") }); + const wire = await events(response); + expect(wire.filter((event) => event.type === "error")).toEqual([]); + const terminals = wire.filter( + (event) => event.type === "run_terminal" && !event.invocation_id, + ); + if (core === "jiuwen") { + expect(terminals).toHaveLength(1); + expect(terminals[0].status, terminals[0].error?.category).toBe( + "completed", + ); + } + expect( + wire.filter( + (event) => + event.type === "final_answer" && + (core === "jiuwen" || event.answer_phase === "complete") && + !event.invocation_id, + ), + ).toHaveLength(1); + const starts = wire.filter((event) => event.type === "subagent_start"); + const ends = wire.filter((event) => event.type === "subagent_end"); + expect(starts).toHaveLength(1); + expect(ends).toHaveLength(1); + const invocation = JSON.parse(starts[0].content).invocation_id; + expect(JSON.parse(ends[0].content).invocation_id).toBe(invocation); + expect( + wire.filter((event) => event.invocation_id === invocation).length, + ).toBeGreaterThan(0); + expect( + wire + .slice(0, wire.indexOf(ends[0]) + 1) + .filter( + (event) => + event.type === "tool" && + event.tool_name === "nexent_execute_python" && + !event.invocation_id, + ), + ).toEqual([]); + expect(fixture("verify-docx", cid, marker).downloaded_docx_verified).toBe( + true, + ); + await expect(child().getByText("Completed", { exact: true })).toBeVisible(); + await expect(answers().first()).toContainText(marker); + const saved = fixture("nested", cid); + expect(saved.starts).toBe(1); + expect(saved.ends).toBe(1); + expect(saved.nested_units).toBeGreaterThan(0); + expect(saved.messages.map((row: any) => row.status)).toEqual([ + "completed", + "completed", + ]); + original = saved.messages; + outcomes.push({ + actual_child: true, + live_running_card: true, + matched_boundaries: true, + child_attributed_output: true, + downloaded_docx_verified: true, + }); + save(); + }); + await contract.step(steps[1], async () => { + const before = runs; + await page.goto(`${base}/zh/newchat?thread_id=${cid}`); + await page.reload(); + await openChildGroup(); + await expect(child()).toBeVisible(); + await expect(child().getByText("Completed", { exact: true })).toBeVisible(); + const trigger = child().locator("button").first(); + if ((await trigger.getAttribute("data-state")) === "closed") + await trigger.click(); + await expect(child()).toContainText("docx"); + await expect(answers().first()).toContainText(marker); + expect(fixture("nested", cid).messages).toEqual(original); + expect(runs).toBe(before); + await page.screenshot({ + path: join(contract.caseDir, "child-history.png"), + }); + outcomes.push({ + historical_child: true, + identical_rows: true, + no_history_execution: true, + }); + save(); + }); + await contract.step(steps[2], async () => { + const wire = await events( + await send( + "不要再次生成文件或调用工具。请根据上一轮结果,只回复文件名和文档中的验证标记。", + ), + ); + expect( + wire.filter((event) => + ["error", "tool", "subagent_start"].includes(event.type), + ), + ).toEqual([]); + await expect(answers().last()).toContainText(marker); + await page.reload(); + await expect(answers()).toHaveCount(2); + await openChildGroup(); + await expect(child()).toBeVisible(); + expect(fixture("nested", cid).messages.slice(0, 2)).toEqual(original); + expect(errors).toEqual([]); + outcomes.push({ followup_without_replay: true, source_isolated: true }); + save(); + }); + const assertions = contract.expectedIds("assertions"); + await contract.assertion(assertions[0], async () => + expect( + outcomes.some( + (row) => + row.actual_child && + row.live_running_card && + row.downloaded_docx_verified, + ), + ).toBe(true), + ); + await contract.assertion(assertions[1], async () => + expect( + outcomes.some((row) => row.historical_child && row.no_history_execution), + ).toBe(true), + ); + await contract.assertion(assertions[2], async () => + expect(outcomes.some((row) => row.followup_without_replay)).toBe(true), + ); +}); diff --git a/test-e2e/changes/refactors/SHARED-AGENT-POLICY-20261030.yaml b/test-e2e/changes/refactors/SHARED-AGENT-POLICY-20261030.yaml new file mode 100644 index 0000000000..686926c408 --- /dev/null +++ b/test-e2e/changes/refactors/SHARED-AGENT-POLICY-20261030.yaml @@ -0,0 +1,45 @@ +schema_version: '1.0' +change: + change_id: SHARED-AGENT-POLICY-20261030 + change_type: refactor + title: Share Agent behavior prompts and delegation policy across Cores + description: Separate shared business rules and run-local delegation from native + and Python execution protocols. + feature_contract_changed: true + affected_features: + existing: + - JIUWEN-OUTPUT + modified: + - JIUWEN-RESOURCES + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D3-002 + - JIUWEN-RESOURCES-D5-003 + modified: + - JIUWEN-DELEGATION-D4-001 + added: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Both Cores consume the same protocol-neutral policy and preserve configured + business prompts. + - Shared current-query delegation requires successful authorized dispatch with + bounded correction. + - Smolagents blocks direct actions and premature finals; Jiuwen native and text + paths retain semantics. + - No forced delegation for negation quotation capability inquiries history or + ordinary follow-up. + reason: Unit policy and protocol gaps complement existing real runtime and strengthened + two-Core browser proof. + test_selectors: + - test/sdk/core/agents/test_shared_agent_policy.py + - test/sdk/core/agents/test_core_agent.py + - test/sdk/core/agents/test_jiuwen_delegation.py + - test/sdk/core/agents/test_agent_prompt_composer.py + - test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py + - test/sdk/core/agents/test_direct_prompt_resources.py + notes: Owning SPEC SP-001 through SP-003. No new D1, SQL, model migration or frontend + product fork. diff --git a/test-e2e/features/JIUWEN-RESOURCES/feature.yaml b/test-e2e/features/JIUWEN-RESOURCES/feature.yaml index 2219ded5ce..e108ead402 100644 --- a/test-e2e/features/JIUWEN-RESOURCES/feature.yaml +++ b/test-e2e/features/JIUWEN-RESOURCES/feature.yaml @@ -9,6 +9,10 @@ feature: description: M3 resource bridge, lossless core selection and management-side trusted snapshots. business_rules: + - rule_id: JIUWEN-RESOURCES-RULE-005 + description: Smolagents and Jiuwen share common behavior prompts and collaboration policy, and honor explicit latest-request + delegation through an authorized child before direct execution; live and restored nested + events reflect actual execution without replay or fabricated delegation. - rule_id: JIUWEN-RESOURCES-RULE-001 description: Native and text calls share authorized tool/MCP/Skills execution, identity, observations and cleanup. Saved and transient agents pass explicit diff --git a/test/sdk/core/agents/test_direct_prompt_resources.py b/test/sdk/core/agents/test_direct_prompt_resources.py index 218ec4e6ad..f10a0c8c89 100644 --- a/test/sdk/core/agents/test_direct_prompt_resources.py +++ b/test/sdk/core/agents/test_direct_prompt_resources.py @@ -112,7 +112,10 @@ def test_ut_sdk_fps_009_special_names_and_no_redundant_automation_path(): assert not (PROMPTS / "en/memory/dreaming_user_memory.yaml").exists() resources = list(PROMPTS.rglob("*.yaml")) expected = json.loads((ROOT / "test/assets/sdk_prompt_migration_hashes.json").read_text(encoding="utf-8")) - assert {path.relative_to(PROMPTS).as_posix() for path in resources} == set(expected) + shared_response_resources = { + f"{language}/agent/response_style.yaml" for language in ("zh", "en") + } + assert {path.relative_to(PROMPTS).as_posix() for path in resources} == set(expected) | shared_response_resources for resource in resources: if resource.stem.startswith(("nl2agent", "nl2skill", "agent_manager", "agent_worker")): continue @@ -144,7 +147,7 @@ def test_ut_sdk_fps_011_languages_have_matching_paths_fields_and_jinja_variables en_paths = {p.relative_to(PROMPTS / "en") for p in (PROMPTS / "en").rglob("*.yaml")} zh_paths = {p.relative_to(PROMPTS / "zh") for p in (PROMPTS / "zh").rglob("*.yaml")} assert en_paths == zh_paths - assert len(en_paths) == 36 + assert len(en_paths) == 37 assert en_paths == zh_paths def flatten(value, prefix=""): diff --git a/test/sdk/core/agents/test_jiuwen_delegation.py b/test/sdk/core/agents/test_jiuwen_delegation.py new file mode 100644 index 0000000000..0cdc810b21 --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_delegation.py @@ -0,0 +1,278 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit delegation preserves common instructions and precedes side effects.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from test_jiuwen_model_service import completion, message, stream_service +from test_jiuwen_model_service import saved_profile as _saved_profile + +from nexent.core.agents.context.models import ContextItemType +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter +from nexent.core.agents.cores.jiuwen.delegation import DelegationPolicy +from nexent.core.agents.cores.jiuwen.messages import resource_view +from nexent.core.agents.cores.jiuwen.model import JiuwenServiceModel +from nexent.core.agents.cores.jiuwen.protocol import EXECUTION_TOOL, JiuwenProtocolError +from nexent.core.concurrency import RunCancellationScope +from nexent.core.model_service.contracts import AssistantMessage, ToolCall +from nexent.core.model_service.service import ModelServiceError +from nexent.core.utils.observer import MessageObserver + + +saved_profile = _saved_profile + + +def policy(query="调用子智能体,帮我生成一个docx文件"): + return DelegationPolicy( + query, [SimpleNamespace(name="writer", invocation_name="write_file", display_name="文件生成助手")] + ) + + +@pytest.mark.parametrize( + "query", + [ + "调用子智能体,生成docx", + "请调用文件生成助手生成文件", + "Call the subagent to make a file", + "能否帮我调用子智能体生成文件", + "Can you please call the subagent to create a file", + "Delegate this task to the worker", + "Please invoke write_file", + "不要自己生成,请调用子智能体", + "调用子智能体完成‘生成文件’", + ], +) +def test_affirmative_current_delegation_is_required(query): + assert policy(query).pending + + +@pytest.mark.parametrize( + "query", + [ + "生成一个docx文件", + "不要调用子智能体,自己生成", + "我不想调用子智能体", + "你支持调用子智能体吗?", + "能否调用子智能体?", + "Can you call a subagent?", + "Don't call the subagent", + "解释如何调用子智能体", + "为什么没有调用子智能体?", + "解释“调用子智能体”是什么意思", + "Explain how to use the worker", + 'Here is an example: "call the subagent"', + "总结刚才的文件", + ], +) +def test_negative_quoted_explanatory_and_ordinary_requests_do_not_force_delegation(query): + assert not policy(query).required + + +def test_only_authorized_named_collaborator_can_satisfy_the_directive(): + other = SimpleNamespace(name="search", invocation_name=None, display_name="搜索助手") + selected = DelegationPolicy( + "请调用文件生成助手生成文件", + [SimpleNamespace(name="writer", invocation_name="write_file", display_name="文件生成助手"), other], + ) + assert selected.names == {"write_file"} + view = resource_view( + "Current", + [{"name": name, "parameters": {"type": "object"}} for name in ["write_file", "search", "upload_to_s3"]], + ) + constrained = selected.constrain(view) + assert [tool.name for tool in constrained.tool_definitions] == ["write_file"] + assert len(view.tool_definitions) == 3 + selected.record_dispatch("search") + assert selected.pending + with pytest.raises(JiuwenProtocolError, match="DELEGATION_REQUIRED"): + selected.validate_calls([]) + selected.validate_calls([SimpleNamespace(name="write_file")]) + assert selected.pending + selected.record_dispatch("write_file") + assert not selected.pending and selected.constrain(view) is view + + +@pytest.mark.parametrize( + "code", ["print(write_file(task='Make a docx'))", "result = write_file(task='Make a docx')\nprint(result)"] +) +def test_text_delegation_permits_direct_child_call_without_releasing_pending_state(code): + selected = policy() + selected.validate_calls([SimpleNamespace(name=EXECUTION_TOOL, arguments=json.dumps({"code": code}))]) + assert selected.pending + + +@pytest.mark.parametrize( + "code", + [ + "print('Created a file')", + "open('x.docx', 'w').write('x')", + "import os\nwrite_file(task='Create')", + "upload_to_s3(file_path='x')\nwrite_file(task='Create')", + "print = eval\nprint('dangerous')\nwrite_file(task='Create')", + "write_file = print\nwrite_file('fake')", + "getattr(write_file, 'forward')('task')", + "for x in range(2):\n write_file(task='Create')", + "write_file(**{'task':'Create'})", + ], +) +def test_text_bypass_cannot_dispatch_python_or_root_side_effects(code): + with pytest.raises(JiuwenProtocolError, match="DELEGATION_REQUIRED"): + policy().validate_calls([SimpleNamespace(name=EXECUTION_TOOL, arguments=json.dumps({"code": code}))]) + + +def test_missing_collaborator_is_explicit_and_new_run_does_not_inherit_dispatch(): + selected = DelegationPolicy("调用子智能体", []) + with pytest.raises(JiuwenProtocolError, match="DELEGATION_UNAVAILABLE"): + selected.constrain(resource_view("Current", [])) + previous = policy() + previous.record_dispatch("write_file") + assert policy().pending and not policy("只总结上轮结果").pending + + +@pytest.mark.parametrize("fails", [False, True]) +async def test_only_completed_actual_child_entry_releases_delegation(fails): + child = SimpleNamespace(name="write_file", invocation_name=None, display_name=None) + adapter = JiuwenAdapter( + SimpleNamespace( + query="调用子智能体", + observer=MessageObserver(lang="en"), + agent_config=SimpleNamespace(name="parent", worker_agents=[child]), + ) + ) + adapter.scope = RunCancellationScope() + forward = ( + AsyncMock(side_effect=RuntimeError("Worker failed")) if fails else AsyncMock(return_value="Worker result") + ) + call = adapter._business_handler("write_file", forward) + if fails: + with pytest.raises(RuntimeError, match="Worker failed"): + await call(task="Make a document") + else: + assert await call(task="Make a document") == "Worker result" + forward.assert_awaited_once_with(task="Make a document") + assert adapter.delegation.pending is fails + + +TOOLS = [ + {"name": name, "parameters": {"type": "object", "properties": properties}} + for name, properties in [ + ("write_file", {"task": {"type": "string"}}), + ("nexent_execute_python", {"code": {"type": "string"}}), + ] +] + + +def child_message(): + arguments = json.dumps({"task": "Create a document"}) + return AssistantMessage( + content_parts=[], + finish_reason="tool_calls", + tool_calls=[ + ToolCall( + provider_call_id="child-call", + index=0, + name="write_file", + raw_arguments=arguments, + parsed_arguments=json.loads(arguments), + ) + ], + ) + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("bypass", ["final", "python"]) +async def test_bridge_corrects_bypass_before_exposing_an_executable_call(saved_profile, streaming, bypass): + rejected = ( + message("File created") + if bypass == "final" + else AssistantMessage( + content_parts=[], + finish_reason="tool_calls", + tool_calls=[ + ToolCall( + provider_call_id="root-call", + index=0, + name="nexent_execute_python", + raw_arguments='{"code":"print(1)"}', + parsed_arguments={"code": "print(1)"}, + ) + ], + ) + ) + responses = [rejected, child_message()] + if streaming: + service, closed = stream_service(lambda request: [completion(request, responses.pop(0))]) + else: + service = SimpleNamespace(ainvoke=AsyncMock(side_effect=responses)) + recovered = Mock() + selected = policy() + model = JiuwenServiceModel( + service, saved_profile, RunCancellationScope(), delegation=selected, on_recovery=recovered + ) + if streaming: + chunks = [chunk async for chunk in model.stream("Create the document", tools=TOOLS)] + assert [call.name for chunk in chunks for call in chunk.tool_calls or []] == ["write_file"] + assert closed.call_count == 2 + else: + result = await model.invoke("Create the document", tools=TOOLS) + assert [call.name for call in result.tool_calls] == ["write_file"] + assert [tool.name for tool in service.ainvoke.await_args_list[0].args[0].tools] == ["write_file"] + assert model.delegation_corrections == 1 and recovered.call_count == 1 and selected.pending + + +async def test_repeated_refusal_to_delegate_fails_instead_of_returning_success(saved_profile): + service = SimpleNamespace(ainvoke=AsyncMock(return_value=message("I already made the file"))) + model = JiuwenServiceModel(service, saved_profile, RunCancellationScope(), delegation=policy()) + with pytest.raises(ModelServiceError) as failure: + await model.invoke("Create the document", tools=TOOLS) + assert failure.value.error.category == "JIUWEN_DELEGATION_REQUIRED" + assert service.ainvoke.await_count == 2 + with pytest.raises(ModelServiceError): + await model.invoke("Native harness retries the same failed step", tools=TOOLS) + assert service.ainvoke.await_count == 2 + + +def test_common_manager_instruction_survives_native_prompt_compilation(mocker): + items = [ + SimpleNamespace( + type=ContextItemType.SYSTEM, + id="system:" + name, + metadata={"authority": "platform"}, + content={"text": text}, + ) + for name, text in [ + ("manager_orchestration", "COMMON_COLLABORATION_INSTRUCTION"), + ("execution_flow", "CODE_ACTION_ONLY_INSTRUCTION"), + ] + ] + mocker.patch("nexent.core.agents.orchestration.execution._get_authorized_context_items", return_value=items) + mocker.patch("nexent.core.agents.orchestration.execution._get_authorized_history", return_value=[]) + adapter = JiuwenAdapter( + SimpleNamespace( + query="Create a file", + agent_config=SimpleNamespace(name="files", instructions="User duties", description=""), + ) + ) + prompt = adapter._compile_context() + assert "COMMON_COLLABORATION_INSTRUCTION" in prompt + assert "CODE_ACTION_ONLY_INSTRUCTION" not in prompt + + +def test_explicit_delegation_takes_precedence_over_direct_file_guidance(mocker): + mocker.patch("nexent.core.agents.orchestration.execution._get_authorized_context_items", return_value=[]) + mocker.patch("nexent.core.agents.orchestration.execution._get_authorized_history", return_value=[]) + child = SimpleNamespace(name="file_generation_assistant", invocation_name=None, display_name="文件生成助手") + adapter = JiuwenAdapter( + SimpleNamespace( + query="调用子智能体,帮我生成一个docx文件", + agent_config=SimpleNamespace( + name="files", instructions="User duties", description="", worker_agents=[child] + ), + ) + ) + prompt = adapter._compile_context() + assert "file_generation_assistant" in prompt + assert "before direct execution" in prompt diff --git a/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py b/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py index d4264e4a75..02d54776ca 100644 --- a/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py +++ b/test/sdk/core/agents/test_nexent_agent_context_runtime_factory.py @@ -248,5 +248,22 @@ def test_clarification_policy_is_not_injected_into_other_protocols(kind): patch("nexent.core.agents.cores.smolagents.agent.CoreAgent") as core: factory.create_single_agent(config, _managed_context=kind == "child") assert core.call_args.kwargs["enable_clarification"] is False - assert core.call_args.kwargs["context_runtime"].items == [item] + runtime_items = core.call_args.kwargs["context_runtime"].items + assert runtime_items[0] == item + assert [entry.id for entry in runtime_items] == ["system:policy", "system:common_behavior"] + assert all(entry.id != "system:clarification_protocol" for entry in runtime_items) assert config.context_items == [item] + + +def test_common_behavior_keeps_legacy_fallback_tool_instructions(): + factory = _factory() + config = AgentConfig(name="agent", description="test", model_name="main", tools=[]) + with patch.object(factory, "create_model", return_value=MagicMock()), \ + patch("nexent.core.agents.cores.smolagents.agent.CoreAgent") as core: + factory.create_single_agent(config) + runtime = core.call_args.kwargs["context_runtime"] + assert runtime.items == [] + fallback = "Saved business instructions and child_echo tool definition" + runtime.prepare_run(memory=MagicMock(), fallback_system_prompt=fallback) + assert runtime._run_context.items[0].id == "system:fallback" + assert runtime._run_context.items[0].content["text"] == fallback diff --git a/test/sdk/core/agents/test_shared_agent_policy.py b/test/sdk/core/agents/test_shared_agent_policy.py new file mode 100644 index 0000000000..5e6b8392bc --- /dev/null +++ b/test/sdk/core/agents/test_shared_agent_policy.py @@ -0,0 +1,242 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Shared behavior ownership and Core-specific delegation protocol regressions.""" + +import ast +import json +import threading +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from nexent.core.agents.context.models import ContextItemInput, ContextItemType +from nexent.core.agents.cores.jiuwen.delegation import ( + DelegationPolicy as JiuwenDelegation, +) +from nexent.core.agents.cores.smolagents.core_agent import CoreAgent +from nexent.core.agents.cores.smolagents.delegation import ( + DelegationPolicy as SmolagentsDelegation, +) +from nexent.core.agents.cores.smolagents.output_protocol import ( + ModelOutputProtocolExhaustedError, +) +from nexent.core.agents.orchestration.delegation import ( + DelegationError, + DelegationPolicy, +) +from nexent.core.agents.prompt.policies import ( + COMMON_BEHAVIOR_GUIDANCE, + business_system_instructions, + with_common_behavior, +) +from nexent.core.agents.resources.subagents import SubAgentToolWrapper +from nexent.core.prompts import load_prompt + + +def worker(name="writer"): + return SimpleNamespace(name=name, invocation_name=None, display_name="文件生成助手") + + +@pytest.mark.parametrize("factory", [DelegationPolicy, JiuwenDelegation, SmolagentsDelegation]) +@pytest.mark.parametrize( + "query,required", + [ + ("调用子智能体,生成docx", True), + ("请调用文件生成助手生成文档", True), + ("Can you please call the subagent to create a docx", True), + ("Don't call the subagent", False), + ("你支持调用子智能体吗?", False), + ('Explain "call the subagent"', False), + ("只总结上轮的文件名", False), + ("生成一个文档", False), + ], +) +def test_shared_intent_state_and_single_correction(factory, query, required): + policy = factory(query, [worker()]) + assert isinstance(policy, DelegationPolicy) + assert policy.required is required + assert policy.pending is required + assert policy.allow_correction() + assert not policy.allow_correction() + policy.record_dispatch("unbound") + assert policy.pending is required + policy.record_dispatch("writer") + assert not policy.pending + assert factory(query, [worker()]).pending is required + + +@pytest.mark.parametrize("names", [[], ["upload_to_s3"], ["writer", "upload_to_s3"]]) +def test_common_target_contract_rejects_bypass_without_mutating_state(names): + policy = DelegationPolicy("调用子智能体", [worker()]) + with pytest.raises(DelegationError, match="DELEGATION_REQUIRED"): + policy.validate_targets(names) + assert policy.pending + policy.validate_targets(["writer"]) + assert policy.pending + + +def smolagent(): + agent = object.__new__(CoreAgent) + agent._delegation = SmolagentsDelegation("调用子智能体", [worker()]) + agent._use_structured_outputs_internally = False + return agent + + +@pytest.mark.parametrize( + "response", + [ + "File generated", + "File generated", + "open('file.docx', 'w').write('bypass')", + "upload_to_s3(file_path='file.docx')", + "print = eval\nprint('bypass')\nwriter(task='Create')", + "writer(task='Create')Done", + "writer = print\nwriter('fake')", + "import os\nwriter(task='Create')", + ], +) +def test_smolagents_complete_response_gate_precedes_final_and_side_effect_branches(response): + agent = smolagent() + with pytest.raises(DelegationError, match="DELEGATION_REQUIRED"): + agent._validate_delegation_output(response) + assert agent._delegation.pending + + +@pytest.mark.parametrize("structured", [False, True]) +def test_smolagents_proposal_is_not_dispatch_and_success_unlocks_followup(structured): + agent = smolagent() + agent._use_structured_outputs_internally = structured + agent.code_block_tags = ("", "") + code = "result = writer(task='Create a document')\nprint(result)" + response = json.dumps({"code": code}) if structured else f"{code}" + agent._validate_delegation_output(response) + assert agent._delegation.pending + agent._delegation.record_dispatch("writer") + agent._validate_delegation_output("Actual worker result") + + +@pytest.mark.parametrize("repeat_bypass", [False, True]) +def test_smolagents_run_loop_corrects_once_and_stops_repeated_bypass(repeat_bypass): + from smolagents.agents import ActionOutput + from smolagents.memory import FinalAnswerStep + + agent = smolagent() + agent.enable_planning = False + agent.stop_event = threading.Event() + agent.logger = Mock() + agent.final_answer_checks = [] + agent.memory = SimpleNamespace(steps=[]) + agent._resolve_deferred_model_attempt = Mock() + agent._finalize_step = Mock() + agent._collect_step_metrics = Mock() + agent._record_output_protocol = Mock() + agent._cleanup_plan = Mock() + calls = [] + + def step(memory_step): + calls.append(memory_step.step_number) + if len(calls) == 1 or repeat_bypass: + raise DelegationError("DELEGATION_REQUIRED") + assert agent._protocol_repair_messages + agent._delegation.record_dispatch("writer") + yield ActionOutput(output="Verified worker result", is_final_answer=True) + + agent._step_stream = step + if repeat_bypass: + with pytest.raises(ModelOutputProtocolExhaustedError, match="DELEGATION_REQUIRED"): + list(agent._run_stream("Call the subagent", 5)) + assert agent.memory.steps == [] + else: + output = list(agent._run_stream("Call the subagent", 5)) + assert isinstance(output[-1], FinalAnswerStep) + assert len(agent.memory.steps) == 1 + assert calls == [1, 1] + assert agent._delegation.corrections == 1 + + +@pytest.mark.parametrize("forward", [False, True]) +@pytest.mark.parametrize("fails", [False, True]) +def test_actual_shared_resource_wrapper_records_success_only(forward, fails): + policy = DelegationPolicy("调用子智能体", [worker()]) + target = Mock(side_effect=RuntimeError("Worker failed")) if fails else Mock(return_value="Saved file") + inner = SimpleNamespace(name="writer", forward=target) + wrapper = SubAgentToolWrapper(inner, Mock()) + wrapper._on_success = policy.record_dispatch + assert not hasattr(inner, "_on_success") + invoke = wrapper.forward if forward else lambda **kwargs: wrapper._invoke(target, (), kwargs) + if fails: + with pytest.raises(RuntimeError, match="Worker failed"): + invoke(task="Generate") + else: + assert invoke(task="Generate") == "Saved file" + target.assert_called_once_with(task="Generate") + assert policy.pending is fails + + +def item(item_id, text): + return ContextItemInput(id=item_id, type=ContextItemType.SYSTEM, content={"text": text}) + + +def test_shared_business_projection_preserves_custom_policy_and_excludes_execution_protocol(): + inputs = [ + item("system:duty", "Saved business duty"), + item("system:manager_orchestration", "Common collaboration"), + item("system:custom_policy", "Authorized custom policy"), + item("system:execution_flow", "CodeAgent execution syntax"), + item("system:code_norms", "CodeAgent code norms"), + item("system:final_answer_guidance", "CodeAgent final syntax"), + ] + projected = business_system_instructions("Saved instructions", inputs) + assert projected[:4] == [ + "Saved instructions", "Saved business duty", "Common collaboration", "Authorized custom policy", + ] + assert projected[-1].startswith(COMMON_BEHAVIOR_GUIDANCE) + assert "reference_mark" in projected[-1] + assert "CodeAgent" not in "\n".join(projected) + assert len(inputs) == 6 + twice = with_common_behavior(with_common_behavior(inputs)) + assert sum(i.id == "system:common_behavior" for i in twice) == 1 + + +def test_shared_policy_has_no_core_imports_or_execution_tags(): + root = Path(__file__).resolve().parents[4] / "sdk/nexent/core/agents" + for relative in ["prompt/policies.py", "orchestration/delegation.py"]: + tree = ast.parse((root / relative).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + assert ".cores." not in (node.module or "") + assert not (node.module or "").startswith(("smolagents", "openjiuwen")) + for forbidden in ["", "", "nexent_execute_python", "nexent_execute_block"]: + assert forbidden not in COMMON_BEHAVIOR_GUIDANCE + for core in ["jiuwen", "smolagents"]: + source = (root / f"cores/{core}/delegation.py").read_text(encoding="utf-8") + assert "_ACTION" not in source and "_NEGATIVE" not in source + + +@pytest.mark.parametrize("language", ["zh", "en"]) +def test_both_roles_share_response_style_without_copying_execution_protocol(language): + from nexent.core.agents.prompt import AgentPromptBundle, AgentPromptComposer + + style = load_prompt(language, "agent/response_style")["policy"] + assert "reference_mark" in style and "s3://" in style + assert "" not in style and "" not in style + for role in ["manager", "managed"]: + composer = AgentPromptComposer(AgentPromptBundle.from_resource(role=role, language=language)) + inputs = composer.compose_context_inputs( + prompt="Saved duty", language=language, is_manager=role == "manager", + ) + common = next(i for i in inputs if i.id == "system:common_behavior") + assert common.content["text"].count(style) == 1 + protocol = composer.render_system_section("final_answer_guidance") + assert "" in protocol and "reference_mark" not in protocol + projected = "\n".join(business_system_instructions("User prompt", inputs, language)) + assert "Nexent" in projected and "Saved duty" in projected + assert style in projected + assert "" not in projected + + +@pytest.mark.parametrize("factory", [DelegationPolicy, JiuwenDelegation, SmolagentsDelegation]) +def test_non_executing_prompt_consumers_do_not_treat_configuration_text_as_dispatch(factory): + policy = factory("创建一个调用子智能体的智能体", [], enabled=False) + assert not policy.pending and not policy.required and policy.instruction == "" From 592ca2cc573e2d2d3e6e5c485b9ed83ae93ce812 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 30 Oct 2026 18:48:10 +0800 Subject: [PATCH 13/26] refactor(sdk): finish canonical imports and regression migration Use canonical context/configuration imports in backend, benchmarks and isolated regression stubs. Align sandbox and context tests with the already migrated SDK, retain terminal MCP error guards, and include generated navigation for the Jiuwen feature contracts. Remove duplicate copyright lines without changing model modules. Validation: isolated SDK suites 1,616 passed; terminal guard 5 passed; benchmark CLI imports, copyright and formal asset checks passed. --- backend/management/services/agent/context.py | 3 +- sdk/benchmark/README.md | 29 ++-- sdk/benchmark/eventqa_eval/run_eventqa.py | 1 - .../longmemeval_eval/run_longmemeval.py | 1 - .../manual_cases/summary_inspector.py | 19 ++- sdk/benchmark/tools/ctx_debugger/README.md | 4 +- .../JIUWEN-MCP-TERMINAL-GUARD-20261028.yaml | 46 +++++++ test-e2e/features/AGENT-CORE-CONFIG/cases.md | 5 + test-e2e/features/JIUWEN-CONTEXT/cases.md | 7 + .../features/JIUWEN-MODEL-BRIDGE/cases.md | 6 + test-e2e/features/JIUWEN-OUTPUT/cases.md | 8 ++ test-e2e/features/JIUWEN-RECOVERY/cases.md | 5 + test-e2e/features/JIUWEN-RELEASE/cases.md | 5 + test-e2e/features/JIUWEN-RESOURCES/cases.md | 13 ++ test/backend/app/test_evaluation_set_app.py | 2 +- .../backend/app/test_knowledge_summary_app.py | 4 +- test/backend/app/test_skill_app.py | 4 +- test/backend/app/test_user_app.py | 4 +- .../consts/test_community_mcp_models.py | 4 +- .../services/test_auto_summary_scheduler.py | 4 +- .../services/test_data_process_service.py | 4 +- .../services/test_memory_config_service.py | 6 +- .../services/test_model_health_service.py | 4 +- .../services/test_model_management_service.py | 6 +- test/backend/services/test_prompt_service.py | 4 +- .../services/test_remote_mcp_service.py | 4 +- test/backend/services/test_user_service.py | 4 +- .../services/test_vectordatabase_service.py | 6 +- test/backend/test_runtime_service.py | 2 +- test/backend/utils/test_auth_utils.py | 2 +- test/backend/utils/test_llm_utils.py | 6 +- .../test_jiuwen_context_terminal_guard.py | 68 +++++++++ .../test_nexent_agent_mcp_parameters.py | 5 +- test/sdk/core/agents/test_sandbox.py | 4 +- .../test_sandbox_tool_bridge_lifecycle.py | 4 +- .../sdk/core/context_runtime/test_runtimes.py | 129 +++++------------- 36 files changed, 281 insertions(+), 151 deletions(-) create mode 100644 test-e2e/changes/bugs/JIUWEN-MCP-TERMINAL-GUARD-20261028.yaml create mode 100644 test-e2e/features/AGENT-CORE-CONFIG/cases.md create mode 100644 test-e2e/features/JIUWEN-CONTEXT/cases.md create mode 100644 test-e2e/features/JIUWEN-MODEL-BRIDGE/cases.md create mode 100644 test-e2e/features/JIUWEN-OUTPUT/cases.md create mode 100644 test-e2e/features/JIUWEN-RECOVERY/cases.md create mode 100644 test-e2e/features/JIUWEN-RELEASE/cases.md create mode 100644 test-e2e/features/JIUWEN-RESOURCES/cases.md create mode 100644 test/sdk/core/agents/test_jiuwen_context_terminal_guard.py diff --git a/backend/management/services/agent/context.py b/backend/management/services/agent/context.py index 8c3e23d0d4..d7d50e1042 100644 --- a/backend/management/services/agent/context.py +++ b/backend/management/services/agent/context.py @@ -8,8 +8,7 @@ from contracts.constants.constants import MESSAGE_ROLE from contracts.agent import AgentRunContext -from nexent.core.agents.context import ContextItemInput -from nexent.core.agents.contracts.context import ContextInput +from nexent.core.agents.contracts.context import ContextInput, ContextItemInput from nexent.core.agents.prompt.user_context import ( CURRENT_TIME_MARKER, has_current_time_marker, diff --git a/sdk/benchmark/README.md b/sdk/benchmark/README.md index 5f6728cef6..8ec87464e6 100644 --- a/sdk/benchmark/README.md +++ b/sdk/benchmark/README.md @@ -303,22 +303,27 @@ for check in summary_checks: Run compression on pure text pairs without agent, using the same prompt and schema: ```python -from nexent.core.agents.context import compress_history_offline - -result = compress_history_offline( - pairs=[("What user said", "What assistant did"), ...], - model=llm_model, - config=ContextManagerConfig(), +from nexent.core.agents.context.config import ContextManagerConfig +from nexent.core.agents.context.history_compression import HistorySummaryInput +from nexent.core.agents.context.llm_summary import LLMSummary +from nexent.core.agents.context.step_renderer import StepRenderer +from nexent.core.agents.cores.smolagents.context import adapt_context_model + +summary_input = HistorySummaryInput( + previous_summary_markdown=None, + new_turns=(("What user said", "What assistant did"),), + covered_through_message_id=0, +).render() +result = LLMSummary(ContextManagerConfig(), StepRenderer()).generate_summary( + summary_input, adapt_context_model(llm_model), call_type="history_summary", prompt_type="initial", ) -# result["summary"]: compressed summary -# result["is_incremental"]: whether incremental compression was used -# result["is_fallback"]: whether LLM failed and used fallback -# result["input_text"]: raw text fed to LLM (for debugging) +# result.summary_text: compressed summary, or None when generation failed +# result.records: compression call records -eval_text(result["summary"], {"must_contain": ["key_filename"]}) +eval_text(result.summary_text or "", {"must_contain": ["key_filename"]}) ``` Offline approach advantages: - No need to run agent, just one LLM call for compression - No dependency on AgentMemory, ActionStep and other runtime objects -- Suitable for batch evaluation of different prompt/schema impacts on compression quality \ No newline at end of file +- Suitable for batch evaluation of different prompt/schema impacts on compression quality diff --git a/sdk/benchmark/eventqa_eval/run_eventqa.py b/sdk/benchmark/eventqa_eval/run_eventqa.py index b7f8f385ee..9f1daa6c67 100644 --- a/sdk/benchmark/eventqa_eval/run_eventqa.py +++ b/sdk/benchmark/eventqa_eval/run_eventqa.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run the EventQA benchmark with the nexent agent. EventQA (MemoryAgentBench) gives a full novel plus 100 six-choice diff --git a/sdk/benchmark/longmemeval_eval/run_longmemeval.py b/sdk/benchmark/longmemeval_eval/run_longmemeval.py index 7b76b5a7b7..5c6c32665e 100644 --- a/sdk/benchmark/longmemeval_eval/run_longmemeval.py +++ b/sdk/benchmark/longmemeval_eval/run_longmemeval.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Run the LongMemEval (S*) benchmark with the nexent agent. LongMemEval (S*) from MemoryAgentBench gives 5 long multi-session dialogues diff --git a/sdk/benchmark/manual_cases/summary_inspector.py b/sdk/benchmark/manual_cases/summary_inspector.py index 8e4dcf5560..31bc4258fc 100644 --- a/sdk/benchmark/manual_cases/summary_inspector.py +++ b/sdk/benchmark/manual_cases/summary_inspector.py @@ -4,7 +4,7 @@ Standalone Summary Inspector — quick evaluation of compression prompt/schema quality. Completely independent from test_benchmark.py and the cases/ directory. -Uses compress_history_offline to compress history and checks whether the +Uses canonical history-summary inputs and LLMSummary to check whether the resulting summary retains key information. No agent runs needed — just one LLM call per inspection + text-based checks. @@ -42,7 +42,11 @@ from dotenv import load_dotenv load_dotenv() -from nexent.core.agents.context import ContextManagerConfig, compress_history_offline +from nexent.core.agents.context.config import ContextManagerConfig +from nexent.core.agents.context.history_compression import HistorySummaryInput +from nexent.core.agents.context.llm_summary import LLMSummary +from nexent.core.agents.context.step_renderer import StepRenderer +from nexent.core.agents.cores.smolagents.context import adapt_context_model from nexent.core.agents.contracts.config import ModelConfig from nexent.core.models.openai_llm import OpenAIModel @@ -176,7 +180,16 @@ def run_inspection( print(f" History: {len(history)} messages -> {len(pairs)} pairs") # Compress - result = compress_history_offline(pairs=pairs, model=model, config=config) + summary_input = HistorySummaryInput(None, tuple(pairs), 0).render() + generated = LLMSummary(config, StepRenderer()).generate_summary( + summary_input, adapt_context_model(model), call_type="history_summary", prompt_type="initial", + ) + result = { + "summary": generated.summary_text, + "is_fallback": not bool(generated.summary_text), + "is_incremental": False, + "input_chars": len(summary_input), + } summary = result.get("summary") or "" is_fallback = result.get("is_fallback", False) is_incremental = result.get("is_incremental", False) diff --git a/sdk/benchmark/tools/ctx_debugger/README.md b/sdk/benchmark/tools/ctx_debugger/README.md index 5640cff937..071d7c5d74 100644 --- a/sdk/benchmark/tools/ctx_debugger/README.md +++ b/sdk/benchmark/tools/ctx_debugger/README.md @@ -160,7 +160,7 @@ Attach debugger to an agent or `ContextManager`. ```python from ctx_debugger import attach_debugger -from nexent.core.agents.context import ContextManager +from nexent.core.agents.context.manager import ContextManager cm = ContextManager(config=...) attach_debugger(cm, trace_path="/tmp/run.jsonl") # Only attach compression layer @@ -255,4 +255,4 @@ Debugger "simulates/eavesdrops" on Nexent behavior, thus soft coupling exists— - **Main LLM calls default only store digest**: Compression LLM calls' input messages and output already stored verbatim in full (each message with `text`, output with `output_full`); Main LLM calls default still truncated digest, need full text pass `capture_full_messages=True` to `attach_debugger`. Interactive REPL already defaults this option on, so `/context` can see full text. - **Trace file size unlimited**: Long session could be tens of MB; `inspector` currently one-time loads into memory. - **Multi-agent nesting**: Each attach one run_id; interactive session uses `existing=` to reuse same debugger to unify run_id. -- **Interactive REPL requires real TTY**: Pipe feeding input works, but experience designed for interactive. \ No newline at end of file +- **Interactive REPL requires real TTY**: Pipe feeding input works, but experience designed for interactive. diff --git a/test-e2e/changes/bugs/JIUWEN-MCP-TERMINAL-GUARD-20261028.yaml b/test-e2e/changes/bugs/JIUWEN-MCP-TERMINAL-GUARD-20261028.yaml new file mode 100644 index 0000000000..ae96733677 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-MCP-TERMINAL-GUARD-20261028.yaml @@ -0,0 +1,46 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-MCP-TERMINAL-GUARD-20261028 + change_type: bugfix + bug_id: JIUWEN-MCP-TERMINAL-GUARD-20261028 + title: Stop model preparation after a terminal MCP resource failure + description: Resource D5 exposed a race where the native loop sent additional model requests + before the outer stream consumer observed an already recorded MCP timeout. Check the same + terminal error before and after CM preparation without changing cancellation or CM policy. + observed_behavior: A native timeout variant recorded one MCP invocation but four model requests + because the outer stream consumer observed the terminal resource error after native retries. + expected_behavior: A known terminal MCP error blocks every later main-model preparation, + preserves the original failure and drains owners before independent recovery. + escaped_stage: D3 + coverage_gap: + type: no_gap + description: Existing resource D5 already detected excess requests under the failing schedule. + Add isolated guard branch assertions and retain the exact runtime call-count requirement. + feature_contract_changed: false + no_case_change_reason: Existing resource D5 already requires exactly one model request and + one blocked MCP invocation, zero side effects, drained owners and successful independent recovery. + affected_features: + existing: + - JIUWEN-RESOURCES + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-RESOURCES-D5-001 + - JIUWEN-RESOURCES-D3-001 + modified: [] + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - A recorded terminal MCP error blocks CM preparation and model sending. + - A terminal error recorded during CM preparation blocks the prepared model request. + - Healthy resources preserve the prepared message result and exception identity. + reason: Existing D5 caught excess model requests; isolated guards need branch and + exact error-identity assertions without duplicating the runtime Case as a new D1. + test_selectors: + - test/sdk/core/agents/test_jiuwen_context_terminal_guard.py + notes: Observed during SDK R3 verification. Keep the existing strict formal assertions, + ModelService transport ownership, saved CM policy and failed-versus-stopped outcome. diff --git a/test-e2e/features/AGENT-CORE-CONFIG/cases.md b/test-e2e/features/AGENT-CORE-CONFIG/cases.md new file mode 100644 index 0000000000..e3dd469bc5 --- /dev/null +++ b/test-e2e/features/AGENT-CORE-CONFIG/cases.md @@ -0,0 +1,5 @@ +# AGENT-CORE-CONFIG 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D4 [AGENT-CORE-UI-D4-001](../../cases/AGENT-CORE-UI-D4-001/case.yaml) · active diff --git a/test-e2e/features/JIUWEN-CONTEXT/cases.md b/test-e2e/features/JIUWEN-CONTEXT/cases.md new file mode 100644 index 0000000000..1336d6194f --- /dev/null +++ b/test-e2e/features/JIUWEN-CONTEXT/cases.md @@ -0,0 +1,7 @@ +# JIUWEN-CONTEXT 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [JIUWEN-CONTEXT-D3-001](../../cases/JIUWEN-CONTEXT-D3-001/case.yaml) · active +- D4 [JIUWEN-CONTEXT-D4-001](../../cases/JIUWEN-CONTEXT-D4-001/case.yaml) · active +- D5 [JIUWEN-CONTEXT-D5-001](../../cases/JIUWEN-CONTEXT-D5-001/case.yaml) · active diff --git a/test-e2e/features/JIUWEN-MODEL-BRIDGE/cases.md b/test-e2e/features/JIUWEN-MODEL-BRIDGE/cases.md new file mode 100644 index 0000000000..e75bec9c1d --- /dev/null +++ b/test-e2e/features/JIUWEN-MODEL-BRIDGE/cases.md @@ -0,0 +1,6 @@ +# JIUWEN-MODEL-BRIDGE 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [JIUWEN-MODEL-BRIDGE-D3-001](../../cases/JIUWEN-MODEL-BRIDGE-D3-001/case.yaml) · active +- D5 [JIUWEN-MODEL-BRIDGE-D5-001](../../cases/JIUWEN-MODEL-BRIDGE-D5-001/case.yaml) · active diff --git a/test-e2e/features/JIUWEN-OUTPUT/cases.md b/test-e2e/features/JIUWEN-OUTPUT/cases.md new file mode 100644 index 0000000000..b1b9ef50e1 --- /dev/null +++ b/test-e2e/features/JIUWEN-OUTPUT/cases.md @@ -0,0 +1,8 @@ +# JIUWEN-OUTPUT 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [JIUWEN-OUTPUT-D3-001](../../cases/JIUWEN-OUTPUT-D3-001/case.yaml) · active +- D4 [JIUWEN-OUTPUT-D4-001](../../cases/JIUWEN-OUTPUT-D4-001/case.yaml) · active +- D4 [JIUWEN-REAL-UI-D4-001](../../cases/JIUWEN-REAL-UI-D4-001/case.yaml) · active +- D5 [JIUWEN-OUTPUT-D5-001](../../cases/JIUWEN-OUTPUT-D5-001/case.yaml) · active diff --git a/test-e2e/features/JIUWEN-RECOVERY/cases.md b/test-e2e/features/JIUWEN-RECOVERY/cases.md new file mode 100644 index 0000000000..f4a770ba85 --- /dev/null +++ b/test-e2e/features/JIUWEN-RECOVERY/cases.md @@ -0,0 +1,5 @@ +# JIUWEN-RECOVERY 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D5 [JIUWEN-RECOVERY-D5-001](../../cases/JIUWEN-RECOVERY-D5-001/case.yaml) · active diff --git a/test-e2e/features/JIUWEN-RELEASE/cases.md b/test-e2e/features/JIUWEN-RELEASE/cases.md new file mode 100644 index 0000000000..b489e4438b --- /dev/null +++ b/test-e2e/features/JIUWEN-RELEASE/cases.md @@ -0,0 +1,5 @@ +# JIUWEN-RELEASE 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [JIUWEN-RELEASE-D3-001](../../cases/JIUWEN-RELEASE-D3-001/case.yaml) · active diff --git a/test-e2e/features/JIUWEN-RESOURCES/cases.md b/test-e2e/features/JIUWEN-RESOURCES/cases.md new file mode 100644 index 0000000000..cce3dd088b --- /dev/null +++ b/test-e2e/features/JIUWEN-RESOURCES/cases.md @@ -0,0 +1,13 @@ +# JIUWEN-RESOURCES 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [JIUWEN-IMAGE-D3-001](../../cases/JIUWEN-IMAGE-D3-001/case.yaml) · active +- D3 [JIUWEN-RESOURCES-D3-001](../../cases/JIUWEN-RESOURCES-D3-001/case.yaml) · active +- D3 [JIUWEN-RESOURCES-D3-002](../../cases/JIUWEN-RESOURCES-D3-002/case.yaml) · active +- D3 [JIUWEN-RESOURCES-D3-003](../../cases/JIUWEN-RESOURCES-D3-003/case.yaml) · active +- D4 [JIUWEN-DELEGATION-D4-001](../../cases/JIUWEN-DELEGATION-D4-001/case.yaml) · active +- D4 [JIUWEN-SKILL-FILES-D4-001](../../cases/JIUWEN-SKILL-FILES-D4-001/case.yaml) · active +- D5 [JIUWEN-RESOURCES-D5-001](../../cases/JIUWEN-RESOURCES-D5-001/case.yaml) · active +- D5 [JIUWEN-RESOURCES-D5-002](../../cases/JIUWEN-RESOURCES-D5-002/case.yaml) · active +- D5 [JIUWEN-RESOURCES-D5-003](../../cases/JIUWEN-RESOURCES-D5-003/case.yaml) · active diff --git a/test/backend/app/test_evaluation_set_app.py b/test/backend/app/test_evaluation_set_app.py index 50455199c5..8763674837 100644 --- a/test/backend/app/test_evaluation_set_app.py +++ b/test/backend/app/test_evaluation_set_app.py @@ -82,7 +82,7 @@ def _register_package(name: str) -> types.ModuleType: "nexent", "nexent.core", "nexent.core.agents", - "nexent.core.agents.agent_model", + "nexent.core.agents.contracts.config", "nexent.core.utils", "nexent.memory", "nexent.monitor", diff --git a/test/backend/app/test_knowledge_summary_app.py b/test/backend/app/test_knowledge_summary_app.py index de9b2dd463..fb10850073 100644 --- a/test/backend/app/test_knowledge_summary_app.py +++ b/test/backend/app/test_knowledge_summary_app.py @@ -35,7 +35,7 @@ sys.modules['nexent.core.agents'] = nexent_core_agents -nexent_core_agents_agent_model = types.ModuleType('nexent.core.agents.agent_model') +nexent_core_agents_agent_model = types.ModuleType('nexent.core.agents.contracts.config') class MockToolConfig: @@ -52,7 +52,7 @@ def model_validate(cls, value): nexent_core_agents_agent_model.ToolConfig = MockToolConfig nexent_core_agents_agent_model.AgentVerificationConfig = MockAgentVerificationConfig -sys.modules['nexent.core.agents.agent_model'] = nexent_core_agents_agent_model +sys.modules['nexent.core.agents.contracts.config'] = nexent_core_agents_agent_model nexent_nexent_vector_database = types.ModuleType('nexent.vector_database') sys.modules['nexent.vector_database'] = nexent_nexent_vector_database nexent_nexent_vector_database = types.ModuleType('nexent.vector_database.base') diff --git a/test/backend/app/test_skill_app.py b/test/backend/app/test_skill_app.py index dfe8a604dd..9cf8c8b289 100644 --- a/test/backend/app/test_skill_app.py +++ b/test/backend/app/test_skill_app.py @@ -37,7 +37,7 @@ class SkillInstanceInfoRequest(BaseModel): nexent_mock = types.ModuleType('nexent') nexent_core_mock = types.ModuleType('nexent.core') nexent_core_agents_mock = types.ModuleType('nexent.core.agents') -nexent_core_agents_agent_model_mock = types.ModuleType('nexent.core.agents.agent_model') +nexent_core_agents_agent_model_mock = types.ModuleType('nexent.core.agents.contracts.config') nexent_core_models_mock = types.ModuleType('nexent.core.models') nexent_core_models_prompt_cache_mock = types.ModuleType('nexent.core.models.prompt_cache') nexent_skills_mock = types.ModuleType('nexent.skills') @@ -50,7 +50,7 @@ class SkillInstanceInfoRequest(BaseModel): sys.modules['nexent'] = nexent_mock sys.modules['nexent.core'] = nexent_core_mock sys.modules['nexent.core.agents'] = nexent_core_agents_mock -sys.modules['nexent.core.agents.agent_model'] = nexent_core_agents_agent_model_mock +sys.modules['nexent.core.agents.contracts.config'] = nexent_core_agents_agent_model_mock sys.modules['nexent.core.models'] = nexent_core_models_mock sys.modules['nexent.core.models.prompt_cache'] = nexent_core_models_prompt_cache_mock sys.modules['nexent.skills'] = nexent_skills_mock diff --git a/test/backend/app/test_user_app.py b/test/backend/app/test_user_app.py index 4c792a7616..ca87c1b054 100644 --- a/test/backend/app/test_user_app.py +++ b/test/backend/app/test_user_app.py @@ -30,8 +30,8 @@ class MockToolConfig(BaseModel): parameters: dict = {} -sys.modules['nexent.core.agents.agent_model'] = MagicMock() -sys.modules['nexent.core.agents.agent_model'].ToolConfig = MockToolConfig +sys.modules['nexent.core.agents.contracts.config'] = MagicMock() +sys.modules['nexent.core.agents.contracts.config'].ToolConfig = MockToolConfig sys.modules['nexent.storage'] = MagicMock() sys.modules['nexent.storage.storage_client_factory'] = MagicMock() sys.modules['nexent.storage.minio_config'] = MagicMock() diff --git a/test/backend/consts/test_community_mcp_models.py b/test/backend/consts/test_community_mcp_models.py index 1a9fd9907c..ff20e9e4e9 100644 --- a/test/backend/consts/test_community_mcp_models.py +++ b/test/backend/consts/test_community_mcp_models.py @@ -15,13 +15,13 @@ sys.modules.setdefault("botocore.client", MagicMock()) sys.modules.setdefault("botocore.exceptions", MagicMock()) -_agent_model_mod = types.ModuleType("nexent.core.agents.agent_model") +_agent_model_mod = types.ModuleType("nexent.core.agents.contracts.config") _agent_model_mod.AgentVerificationConfig = MagicMock() _agent_model_mod.ToolConfig = MagicMock() sys.modules.setdefault("nexent", MagicMock()) sys.modules.setdefault("nexent.core", MagicMock()) sys.modules.setdefault("nexent.core.agents", MagicMock()) -sys.modules["nexent.core.agents.agent_model"] = _agent_model_mod +sys.modules["nexent.core.agents.contracts.config"] = _agent_model_mod from contracts.tool import CommunityPublishRequest, CommunityReviewActionRequest, CommunityStatusUpdateRequest, CommunityUpdateRequest from contracts.skill import SkillRepositoryListingCreateRequest diff --git a/test/backend/services/test_auto_summary_scheduler.py b/test/backend/services/test_auto_summary_scheduler.py index cd6b0e69f2..1092bbb55b 100644 --- a/test/backend/services/test_auto_summary_scheduler.py +++ b/test/backend/services/test_auto_summary_scheduler.py @@ -189,10 +189,10 @@ def __init__(self, *a, **k): sys.modules['nexent.core.models.tts_model'] = tts_mod # Mock agent modules -agent_model_mod = types.ModuleType('nexent.core.agents.agent_model') +agent_model_mod = types.ModuleType('nexent.core.agents.contracts.config') agent_model_mod.ToolConfig = object sys.modules['nexent.core.agents'] = types.ModuleType('nexent.core.agents') -sys.modules['nexent.core.agents.agent_model'] = agent_model_mod +sys.modules['nexent.core.agents.contracts.config'] = agent_model_mod # Mock jinja2 jinja2_mod = types.ModuleType('jinja2') diff --git a/test/backend/services/test_data_process_service.py b/test/backend/services/test_data_process_service.py index a1c6028d98..0fee40599c 100644 --- a/test/backend/services/test_data_process_service.py +++ b/test/backend/services/test_data_process_service.py @@ -37,8 +37,8 @@ sys.modules['nexent'] = MagicMock() sys.modules['nexent.core'] = MagicMock() sys.modules['nexent.core.agents'] = MagicMock() -sys.modules['nexent.core.agents.agent_model'] = MagicMock() -sys.modules['nexent.core.agents.agent_model'].ToolConfig = MagicMock() +sys.modules['nexent.core.agents.contracts.config'] = MagicMock() +sys.modules['nexent.core.agents.contracts.config'].ToolConfig = MagicMock() # Add missing nexent.data_process module mock sys.modules['nexent.data_process'] = MagicMock() diff --git a/test/backend/services/test_memory_config_service.py b/test/backend/services/test_memory_config_service.py index 0f215cdbff..b63ab1c0e3 100644 --- a/test/backend/services/test_memory_config_service.py +++ b/test/backend/services/test_memory_config_service.py @@ -35,8 +35,8 @@ class MemoryAgentShareMode(str, Enum): sys.modules["contracts.constants.environment"] = consts_const install_business_constant_stubs(consts_const) -# Stub nexent.core.agents.agent_model for MemoryContext and MemoryUserConfig -agent_model_mod = types.ModuleType("nexent.core.agents.agent_model") +# Stub nexent.core.agents.contracts.config for MemoryContext and MemoryUserConfig +agent_model_mod = types.ModuleType("nexent.core.agents.contracts.config") class MemoryUserConfig: def __init__( self, @@ -60,7 +60,7 @@ def __init__(self, user_config, tenant_id, user_id, agent_id, memory_config=None self.agent_id = agent_id agent_model_mod.MemoryUserConfig = MemoryUserConfig agent_model_mod.MemoryContext = MemoryContext -sys.modules["nexent.core.agents.agent_model"] = agent_model_mod +sys.modules["nexent.core.agents.contracts.config"] = agent_model_mod # Fake out management.db.client to prevent boto3/MinIO side effects and provide needed APIs fake_client = types.ModuleType("management.db.client") diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index 99aa6d59ef..f508573cf8 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -37,7 +37,7 @@ def __getattr__(cls, key): "nexent", "nexent.core", "nexent.core.agents", - "nexent.core.agents.agent_model", + "nexent.core.agents.contracts.config", "nexent.core.models", "nexent.core.models.embedding_model", "nexent.core.models.rerank_model", @@ -80,7 +80,7 @@ def __getattr__(cls, key): sys.modules['nexent'] = MockModule() sys.modules['nexent.core'] = MockModule() sys.modules['nexent.core.agents'] = MockModule() -sys.modules['nexent.core.agents.agent_model'] = MockModule() +sys.modules['nexent.core.agents.contracts.config'] = MockModule() sys.modules['nexent.core.models'] = MockModule() sys.modules['nexent.core.models.embedding_model'] = MockModule() diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 6e92001ccb..c87e3dfd57 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -24,14 +24,14 @@ sys.modules["nexent.core"] = mock.MagicMock() if "nexent.core.agents" not in sys.modules: sys.modules["nexent.core.agents"] = mock.MagicMock() -if "nexent.core.agents.agent_model" not in sys.modules: - agent_model_mod = types.ModuleType("nexent.core.agents.agent_model") +if "nexent.core.agents.contracts.config" not in sys.modules: + agent_model_mod = types.ModuleType("nexent.core.agents.contracts.config") class ToolConfig: # minimal stub pass agent_model_mod.ToolConfig = ToolConfig - sys.modules["nexent.core.agents.agent_model"] = agent_model_mod + sys.modules["nexent.core.agents.contracts.config"] = agent_model_mod # Stub boto3 used by management.db.client if "boto3" not in sys.modules: diff --git a/test/backend/services/test_prompt_service.py b/test/backend/services/test_prompt_service.py index 5563f11276..461ec3f683 100644 --- a/test/backend/services/test_prompt_service.py +++ b/test/backend/services/test_prompt_service.py @@ -38,7 +38,7 @@ 'nexent.core.agents.prompt.auxiliary', 'nexent.core.agents.prompt.meta', 'nexent.core.agents', - 'nexent.core.agents.agent_model', + 'nexent.core.agents.contracts.config', 'nexent.core.concurrency', 'nexent.storage', 'nexent.storage.storage_client_factory', @@ -105,7 +105,7 @@ def _render_test_prompt(source, parameters): prompt_assembly_stub = types.ModuleType('nexent.core.agents.prompt') prompt_assembly_stub.__path__ = [str(Path(__file__).resolve().parents[3] / 'sdk/nexent/core/agents/prompt')] sys.modules['nexent.core.agents.prompt'] = prompt_assembly_stub -sys.modules['nexent.core.agents.agent_model'] = nexent_agent_model_mock +sys.modules['nexent.core.agents.contracts.config'] = nexent_agent_model_mock sys.modules['nexent.storage'] = nexent_storage_mock sys.modules['nexent.storage.storage_client_factory'] = nexent_storage_storage_client_factory_mock sys.modules['nexent.storage.minio_config'] = nexent_storage_minio_config_mock diff --git a/test/backend/services/test_remote_mcp_service.py b/test/backend/services/test_remote_mcp_service.py index 8126683e18..d517f65f24 100644 --- a/test/backend/services/test_remote_mcp_service.py +++ b/test/backend/services/test_remote_mcp_service.py @@ -53,7 +53,7 @@ # Pre-mock all nexent submodules that may be referenced downstream for _mod_name in [ - 'nexent.core', 'nexent.core.agents', 'nexent.core.agents.agent_model', + 'nexent.core', 'nexent.core.agents', 'nexent.core.agents.contracts.config', 'nexent.core.models', 'nexent.core.utils', 'nexent.container', 'nexent.memory', ]: @@ -69,7 +69,7 @@ sys.modules[_mod_name] = _mod # Ensure specific attributes needed by imports are present -nexent_agent_model = sys.modules['nexent.core.agents.agent_model'] +nexent_agent_model = sys.modules['nexent.core.agents.contracts.config'] nexent_agent_model.AgentVerificationConfig = MagicMock() nexent_agent_model.ToolConfig = MagicMock() diff --git a/test/backend/services/test_user_service.py b/test/backend/services/test_user_service.py index 94d82ac86c..5fd5ccfa3a 100644 --- a/test/backend/services/test_user_service.py +++ b/test/backend/services/test_user_service.py @@ -23,7 +23,7 @@ sys.modules['nexent'] = MagicMock() sys.modules['nexent.core'] = MagicMock() sys.modules['nexent.core.agents'] = MagicMock() -sys.modules['nexent.core.agents.agent_model'] = MagicMock() +sys.modules['nexent.core.agents.contracts.config'] = MagicMock() sys.modules['nexent.storage'] = MagicMock() sys.modules['nexent.storage.storage_client_factory'] = MagicMock() sys.modules['nexent.storage.minio_config'] = MagicMock() @@ -40,7 +40,7 @@ class MockToolConfig(BaseModel): description: str = "" parameters: dict = {} -sys.modules['nexent.core.agents.agent_model'].ToolConfig = MockToolConfig +sys.modules['nexent.core.agents.contracts.config'].ToolConfig = MockToolConfig # Patch storage client factory before imports patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=MagicMock()).start() diff --git a/test/backend/services/test_vectordatabase_service.py b/test/backend/services/test_vectordatabase_service.py index 65299889fb..fd734fa8d0 100644 --- a/test/backend/services/test_vectordatabase_service.py +++ b/test/backend/services/test_vectordatabase_service.py @@ -308,12 +308,12 @@ def validate(self): nexent_storage_module.minio_config = storage_config_module setattr(nexent_mock, 'storage', nexent_storage_module) -# Mock nexent.core.agents.agent_model +# Mock nexent.core.agents.contracts.config nexent_core_agents_module = _create_package_mock('nexent.core.agents') -nexent_core_agents_agent_model_module = types.ModuleType('nexent.core.agents.agent_model') +nexent_core_agents_agent_model_module = types.ModuleType('nexent.core.agents.contracts.config') nexent_core_agents_agent_model_module.ToolConfig = MagicMock() sys.modules['nexent.core.agents'] = nexent_core_agents_module -sys.modules['nexent.core.agents.agent_model'] = nexent_core_agents_agent_model_module +sys.modules['nexent.core.agents.contracts.config'] = nexent_core_agents_agent_model_module # Mock nexent.core.nlp nexent_core_nlp_module = _create_package_mock('nexent.core.nlp') diff --git a/test/backend/test_runtime_service.py b/test/backend/test_runtime_service.py index bfe52f970e..08907ce5f1 100644 --- a/test/backend/test_runtime_service.py +++ b/test/backend/test_runtime_service.py @@ -90,7 +90,7 @@ def __init__(self, *args, **kwargs): setattr(vector_db_module, "elasticsearch_core", vector_db_es_module) sys.modules['nexent.core.agents'] = MagicMock() -sys.modules['nexent.core.agents.agent_model'] = MagicMock() +sys.modules['nexent.core.agents.contracts.config'] = MagicMock() sys.modules['nexent.storage.storage_client_factory'] = MagicMock() # Patch storage factory and MinIO config validation to avoid errors during initialization diff --git a/test/backend/utils/test_auth_utils.py b/test/backend/utils/test_auth_utils.py index 8cff485bcb..04364c5646 100644 --- a/test/backend/utils/test_auth_utils.py +++ b/test/backend/utils/test_auth_utils.py @@ -176,7 +176,7 @@ def validate(self): sys.modules['nexent'] = MagicMock() sys.modules['nexent.core'] = MagicMock() sys.modules['nexent.core.agents'] = MagicMock() -sys.modules['nexent.core.agents.agent_model'] = MagicMock() +sys.modules['nexent.core.agents.contracts.config'] = MagicMock() sys.modules['boto3'] = MagicMock() sys.modules['psycopg2'] = MagicMock() diff --git a/test/backend/utils/test_llm_utils.py b/test/backend/utils/test_llm_utils.py index a3b1e68c5c..9810e7cc38 100644 --- a/test/backend/utils/test_llm_utils.py +++ b/test/backend/utils/test_llm_utils.py @@ -109,14 +109,14 @@ def _prepare_completion_kwargs(self, *a, **k): nexent_core_pkg = types.ModuleType("nexent.core") nexent_core_pkg.__path__ = [os.path.join(_sdk_nexent_path, "core")] sys.modules["nexent.core"] = nexent_core_pkg -# Stub nexent.core.agents.agent_model: backend/contracts/agent.py (reached via +# Stub nexent.core.agents.contracts.config: backend/contracts/agent.py (reached via # management.db.model.repository) imports AgentVerificationConfig/ToolConfig from # it at module level, and the empty __path__ above blocks the real SDK package. agents_pkg = types.ModuleType("nexent.core.agents") agents_pkg.__path__ = [] sys.modules["nexent.core.agents"] = agents_pkg nexent_core_pkg.agents = agents_pkg -agent_model_mod = types.ModuleType("nexent.core.agents.agent_model") +agent_model_mod = types.ModuleType("nexent.core.agents.contracts.config") class _StubAgentVerificationConfig: # pylint: disable=too-few-public-methods @@ -131,7 +131,7 @@ def __init__(self, *args, **kwargs): agent_model_mod.AgentVerificationConfig = _StubAgentVerificationConfig agent_model_mod.ToolConfig = _StubToolConfig -sys.modules["nexent.core.agents.agent_model"] = agent_model_mod +sys.modules["nexent.core.agents.contracts.config"] = agent_model_mod agents_pkg.agent_model = agent_model_mod gateway_mod = types.ModuleType("nexent.core.gateway") for _name in ("EmbeddingContext", "LLMContext", "LongContextLLMContext", "ModelContext", "VLMContext", "get_gateway"): diff --git a/test/sdk/core/agents/test_jiuwen_context_terminal_guard.py b/test/sdk/core/agents/test_jiuwen_context_terminal_guard.py new file mode 100644 index 0000000000..4344c8612d --- /dev/null +++ b/test/sdk/core/agents/test_jiuwen_context_terminal_guard.py @@ -0,0 +1,68 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Known resource failure must prevent further native-loop model requests.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from nexent.consts.mcp_errors import MCPToolTimeoutError +from nexent.core.agents.cores.jiuwen.adapter import JiuwenAdapter + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_mcp", [False, True]) +async def test_healthy_model_preparation_preserves_messages_and_calls_cm_once(has_mcp): + adapter = object.__new__(JiuwenAdapter) + adapter.mcp = SimpleNamespace(terminal_error=None) if has_mcp else None + prepared = [{"role": "user", "content": "CURRENT"}] + adapter.context_bridge = SimpleNamespace( + prepare_messages=AsyncMock(return_value=prepared) + ) + source, tools = object(), object() + assert await adapter._prepare_model_messages(source, tools) is prepared + adapter.context_bridge.prepare_messages.assert_awaited_once_with(source, tools) + + +@pytest.mark.asyncio +async def test_recorded_mcp_failure_blocks_cm_and_preserves_the_original_error(): + adapter = object.__new__(JiuwenAdapter) + error = MCPToolTimeoutError("controlled timeout") + adapter.mcp = SimpleNamespace(terminal_error=error) + adapter.context_bridge = SimpleNamespace(prepare_messages=AsyncMock()) + with pytest.raises(MCPToolTimeoutError) as captured: + await adapter._prepare_model_messages([], []) + assert captured.value is error + adapter.context_bridge.prepare_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_mcp_failure_during_cm_blocks_the_prepared_request(): + adapter = object.__new__(JiuwenAdapter) + error = MCPToolTimeoutError("controlled timeout") + adapter.mcp = SimpleNamespace(terminal_error=None) + + async def prepare(messages, tools): + adapter.mcp.terminal_error = error + return [{"role": "user", "content": "prepared but must not send"}] + + adapter.context_bridge = SimpleNamespace( + prepare_messages=AsyncMock(side_effect=prepare) + ) + with pytest.raises(MCPToolTimeoutError) as captured: + await adapter._prepare_model_messages([], []) + assert captured.value is error + adapter.context_bridge.prepare_messages.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cm_exception_is_preserved_without_retrying_preparation(): + adapter = object.__new__(JiuwenAdapter) + error = ValueError("controlled CM failure") + adapter.mcp = None + adapter.context_bridge = SimpleNamespace( + prepare_messages=AsyncMock(side_effect=error) + ) + with pytest.raises(ValueError) as captured: + await adapter._prepare_model_messages([], []) + assert captured.value is error + adapter.context_bridge.prepare_messages.assert_awaited_once() diff --git a/test/sdk/core/agents/test_nexent_agent_mcp_parameters.py b/test/sdk/core/agents/test_nexent_agent_mcp_parameters.py index ebbe0e1f6e..88690e83d5 100644 --- a/test/sdk/core/agents/test_nexent_agent_mcp_parameters.py +++ b/test/sdk/core/agents/test_nexent_agent_mcp_parameters.py @@ -1,10 +1,13 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Exercise the NexentAgent MCP dispatch path with configured arguments.""" from threading import Event from types import SimpleNamespace from unittest.mock import MagicMock -from test.sdk.core.agents.test_nexent_agent import MessageObserver, NexentAgent, ToolConfig +from nexent.core.agents.contracts.config import ToolConfig +from nexent.core.agents.cores.smolagents.agent import NexentAgent +from nexent.core.utils.observer import MessageObserver def test_mcp_dispatch_applies_parameters_without_mutating_template(): diff --git a/test/sdk/core/agents/test_sandbox.py b/test/sdk/core/agents/test_sandbox.py index 77818e93dc..770484eb8d 100644 --- a/test/sdk/core/agents/test_sandbox.py +++ b/test/sdk/core/agents/test_sandbox.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """ Unit + integration tests for the sandbox factory (session/system dimensions). @@ -40,7 +42,7 @@ def _load_sandbox_module(): spec = importlib.util.spec_from_file_location( "sandbox_under_test", - os.path.join(SDK_PATH, "nexent", "core", "agents", "sandbox.py"), + os.path.join(SDK_PATH, "nexent", "core", "agents", "sandbox", "execution.py"), ) module = importlib.util.module_from_spec(spec) sys.modules["sandbox_under_test"] = module diff --git a/test/sdk/core/agents/test_sandbox_tool_bridge_lifecycle.py b/test/sdk/core/agents/test_sandbox_tool_bridge_lifecycle.py index 0d1b570acb..963b437600 100644 --- a/test/sdk/core/agents/test_sandbox_tool_bridge_lifecycle.py +++ b/test/sdk/core/agents/test_sandbox_tool_bridge_lifecycle.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Regression coverage for the managed sandbox host-tool bridge.""" import importlib.util @@ -12,7 +14,7 @@ from nexent.core.concurrency.helpers import get_fallback_thread_manager -_SANDBOX_PATH = Path(__file__).resolve().parents[4] / "sdk" / "nexent" / "core" / "agents" / "sandbox.py" +_SANDBOX_PATH = Path(__file__).resolve().parents[4] / "sdk" / "nexent" / "core" / "agents" / "sandbox" / "execution.py" _SPEC = importlib.util.spec_from_file_location("sandbox_bridge_lifecycle_under_test", _SANDBOX_PATH) sandbox_module = importlib.util.module_from_spec(_SPEC) sys.modules[_SPEC.name] = sandbox_module diff --git a/test/sdk/core/context_runtime/test_runtimes.py b/test/sdk/core/context_runtime/test_runtimes.py index c1b5221419..c8c353905b 100644 --- a/test/sdk/core/context_runtime/test_runtimes.py +++ b/test/sdk/core/context_runtime/test_runtimes.py @@ -1,95 +1,22 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Low-dependency tests for the ContextManager runtime.""" + from __future__ import annotations -import importlib.util -import sys import types -from pathlib import Path import pytest -ROOT = Path(__file__).resolve().parents[4] / "sdk" / "nexent" -_BOOTSTRAP_MODULES = ( - "nexent", - "nexent.core", - "nexent.core.agents", - "nexent.core.agents.context", - "nexent.core.agents.context.manager", - "nexent.core.agents.context.models", - "nexent.core.agents.context.run_context", - "nexent.core.agents.cores.smolagents.context", - "nexent.core.agents.cores.smolagents.context_contracts", - "nexent.core.agents.cores.smolagents.context_contracts", - "smolagents.memory", - "smolagents.models", - "smolagents.tools", -) - - -def _load(name: str, relative: str): - spec = importlib.util.spec_from_file_location(name, ROOT / relative) - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - def _bootstrap(): - snapshot = {name: sys.modules.get(name) for name in _BOOTSTRAP_MODULES} - for name, path in ( - ("nexent", ROOT), - ("nexent.core", ROOT / "core"), - ("nexent.core.agents", ROOT / "core" / "agents"), - ("nexent.core.agents.context", ROOT / "core" / "agents" / "context"), - ("nexent.core.agents.cores.smolagents.context_contracts", ROOT / "core" / "context_runtime"), - ): - package = types.ModuleType(name) - package.__path__ = [str(path)] - sys.modules[name] = package - - memory_module = types.ModuleType("smolagents.memory") - - class SystemPromptStep: - def __init__(self, system_prompt): - self.system_prompt = system_prompt - - def to_messages(self): - return [{"role": "system", "content": self.system_prompt}] - - memory_module.SystemPromptStep = SystemPromptStep - memory_module.AgentMemory = type("AgentMemory", (), {}) - memory_module.MemoryStep = type("MemoryStep", (), {}) - sys.modules["smolagents.memory"] = memory_module - - models_module = types.ModuleType("smolagents.models") - models_module.ChatMessage = type("ChatMessage", (), {}) - models_module.Model = type("Model", (), {}) - sys.modules["smolagents.models"] = models_module - tools_module = types.ModuleType("smolagents.tools") - tools_module.Tool = type("Tool", (), {}) - sys.modules["smolagents.tools"] = tools_module - - manager_module = types.ModuleType("nexent.core.agents.context.manager") - manager_module.ContextManager = type("ContextManager", (), {}) - sys.modules[manager_module.__name__] = manager_module - models_module = types.ModuleType("nexent.core.agents.context.models") - models_module.ContextItem = type("ContextItem", (), {}) - models_module.ContextItemInput = type("ContextItemInput", (), {}) - sys.modules[models_module.__name__] = models_module - _load("nexent.core.agents.cores.smolagents.context_contracts", "core/context_runtime/contracts.py") - managed = _load("nexent.core.agents.cores.smolagents.context", "core/agents/context/runtime.py") - return managed, snapshot + from nexent.core.agents.cores.smolagents import context + + return context, {} def _restore(snapshot): - for name in _BOOTSTRAP_MODULES: - previous = snapshot.get(name) - if previous is None: - sys.modules.pop(name, None) - else: - sys.modules[name] = previous + """No global module replacements are needed for the canonical bridge.""" class _Memory: @@ -112,20 +39,28 @@ class _Config: def __init__(self): self.calls = [] self.force_compactions = [] + self._current_item_cache = {} + self._to_json_value = lambda value: value + self.build_context_messages = lambda items: [] - def prepare_run_context(self, *, memory, fallback_system_prompt, items=None): - self.calls.append(("prepare_run_context", fallback_system_prompt, items)) + def prepare_items_context(self, *, fallback_system_prompt, items=None): + self.calls.append(("prepare_items_context", fallback_system_prompt, items)) return types.SimpleNamespace( stable_messages=({"role": "system", "content": "managed stable"},), dynamic_messages=(), - selected_item_types=tuple(str(getattr(item, "type", "unknown")) for item in items or ()), + selected_item_types=tuple( + str(getattr(item, "type", "unknown")) for item in items or () + ), items=tuple(items or ()), ) - def assemble_final_context(self, **kwargs): - self.calls.append(("assemble_final_context", kwargs["purpose"], kwargs.get("tools"))) + def assemble_context_items(self, **kwargs): + self.calls.append( + ("assemble_context_items", kwargs["purpose"], kwargs.get("tools")) + ) self.force_compactions.append(kwargs.get("force_compaction", False)) - contracts = sys.modules["nexent.core.agents.cores.smolagents.context_contracts"] + from nexent.core.agents.contracts import context as contracts + return contracts.FinalContext( messages=[{"role": "system", "content": kwargs["purpose"]}], tools=list(kwargs.get("tools") or ()), @@ -133,7 +68,13 @@ def assemble_final_context(self, **kwargs): ) def get_step_compression_stats(self): - return {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cache_hits": 0, "cache_types": []} + return { + "calls": 0, + "input_tokens": 0, + "output_tokens": 0, + "cache_hits": 0, + "cache_types": [], + } def get_all_compression_stats(self): return {"calls": 1, "records": ["record"]} @@ -175,9 +116,9 @@ def test_managed_runtime_is_thin_context_manager_adapter(): ) assert manager.calls == [ - ("prepare_run_context", "fallback", [item]), - ("assemble_final_context", "step", [{"name": "z"}]), - ("assemble_final_context", "final_answer", None), + ("prepare_items_context", "fallback", [item]), + ("assemble_context_items", "step", [{"name": "z"}]), + ("assemble_context_items", "final_answer", None), ] assert final.messages == [{"role": "system", "content": "step"}] assert final_answer.messages == [{"role": "system", "content": "final_answer"}] @@ -194,6 +135,7 @@ def test_managed_runtime_is_thin_context_manager_adapter(): finally: _restore(snapshot) + def test_managed_runtime_replaces_items_without_mutating_context_manager(): managed_module, snapshot = _bootstrap() try: @@ -204,7 +146,7 @@ def test_managed_runtime_replaces_items_without_mutating_context_manager(): runtime.replace_items([item]) runtime.prepare_run(memory=_Memory(), fallback_system_prompt="fallback") - assert manager.calls[0] == ("prepare_run_context", "fallback", [item]) + assert manager.calls[0] == ("prepare_items_context", "fallback", [item]) finally: _restore(snapshot) @@ -218,7 +160,7 @@ def test_managed_runtime_uses_item_snapshot_without_explicit_prepare_run(): runtime.prepare_step(model=None, memory=_Memory(), current_run_start_idx=0) - assert manager.calls[0] == ("prepare_run_context", "", [item]) + assert manager.calls[0] == ("prepare_items_context", "", [item]) finally: _restore(snapshot) @@ -231,7 +173,10 @@ def test_managed_runtime_forces_compaction_for_both_overflow_recovery_paths(): memory = _Memory() step = runtime.recover_step( - model=None, memory=memory, current_run_start_idx=0, tools=[{"name": "search"}], + model=None, + memory=memory, + current_run_start_idx=0, + tools=[{"name": "search"}], ) final_answer = runtime.recover_final_answer( model=None, From e16275a4e0cfac300ac1de57decdde6c8f0f6708 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 30 Oct 2026 18:48:11 +0800 Subject: [PATCH 14/26] fix(agents): preserve core and context manager on transfer Export and import the saved agent core and context-manager enable flag. Keep smolagents and enabled CM as backward-compatible defaults for older payloads, and reject unsupported cores through the public contract. Validation: 12 focused transfer tests and formal asset checks passed. --- backend/contracts/agent.py | 1 + backend/management/services/agent/transfer.py | 4 + .../JIUWEN-PORTABLE-CORE-CM-20261028.yaml | 46 ++++++++++ .../test_agent_transfer_core_context.py | 84 +++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 test-e2e/changes/bugs/JIUWEN-PORTABLE-CORE-CM-20261028.yaml create mode 100644 test/backend/services/test_agent_transfer_core_context.py diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index f980856239..59c398513a 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -562,6 +562,7 @@ class AgentIDRequest(BaseModel): class ExportAndImportAgentInfo(BaseModel): agent_core: Literal["smolagents", "jiuwen"] = "smolagents" + enable_context_manager: bool = True agent_id: int tenant_id: Optional[str] = None name: str diff --git a/backend/management/services/agent/transfer.py b/backend/management/services/agent/transfer.py index da997ba8b7..8494cd97e2 100644 --- a/backend/management/services/agent/transfer.py +++ b/backend/management/services/agent/transfer.py @@ -425,6 +425,8 @@ async def export_agent_by_agent_id( ) agent_info = ExportAndImportAgentInfo( + agent_core=agent_info.get("agent_core") or "smolagents", + enable_context_manager=bool(agent_info.get("enable_context_manager", True)), agent_id=agent_id, tenant_id=agent_info["tenant_id"], name=agent_info["name"], @@ -600,6 +602,8 @@ async def import_agent_by_agent_id( user_group_ids = _get_user_group_ids(user_id, tenant_id) new_agent = create_agent( agent_info={ + "agent_core": import_agent_info.agent_core, + "enable_context_manager": import_agent_info.enable_context_manager, "name": agent_names["name"], "display_name": agent_names["display_name"], "description": import_agent_info.description, diff --git a/test-e2e/changes/bugs/JIUWEN-PORTABLE-CORE-CM-20261028.yaml b/test-e2e/changes/bugs/JIUWEN-PORTABLE-CORE-CM-20261028.yaml new file mode 100644 index 0000000000..3c26d45000 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-PORTABLE-CORE-CM-20261028.yaml @@ -0,0 +1,46 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-PORTABLE-CORE-CM-20261028 + change_type: bugfix + bug_id: JIUWEN-PORTABLE-CORE-CM-20261028 + title: Preserve selected Core and saved CM switch in portable Agent configuration + description: Real R4 Skill fixture found that export and import omit agent_core. + The portable schema also omits enable_context_manager, losing an explicit false value. + observed_behavior: A Jiuwen Agent exports as the default smolagents and imports lose + its selected Core. A disabled CM switch is absent from export and resets on import. + expected_behavior: Both Core values and both CM switch values round trip unchanged; + legacy files without the fields retain historical smolagents and enabled CM defaults. + escaped_stage: D3 + coverage_gap: + type: missing_assertion + description: Existing transfer UT checked other fields but not Core or CM identity. + The real Skill journey now asserts its owned exported target uses Jiuwen. + feature_contract_changed: false + no_case_change_reason: Lossless configuration and equivalent published Skill clone + are already required. Strengthen the existing fixture assertion without changing its acceptance. + affected_features: + existing: + - JIUWEN-RESOURCES + - JIUWEN-CONTEXT + modified: [] + added: [] + retired: [] + affected_cases: + existing: + - JIUWEN-REAL-UI-D4-001 + modified: + - JIUWEN-SKILL-FILES-D4-001 + added: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Export and import preserve both supported Core choices and both explicit CM states. + - Legacy files retain default Core and CM behavior and invalid Core values are rejected. + - Transfer dependency failures propagate and are never mistaken for a successful import. + reason: Isolated transfer field assertions detect the actual omissions; deployed + Skill and saved Core switch journeys provide the separate API and browser proof. + test_selectors: + - test/backend/services/test_agent_transfer_core_context.py + notes: Found during SDK R4. Keep existing permissions, model mapping, prompts, + immutable version selection, tool and Skill binding policies unchanged. No SQL is required. diff --git a/test/backend/services/test_agent_transfer_core_context.py b/test/backend/services/test_agent_transfer_core_context.py new file mode 100644 index 0000000000..68369c7c35 --- /dev/null +++ b/test/backend/services/test_agent_transfer_core_context.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Portable Agent configuration keeps Core and context policy choices.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from contracts.agent import ExportAndImportAgentInfo +from management.services.agent import transfer + + +def snapshot(core="jiuwen", cm=False): + return { + "agent_core": core, "enable_context_manager": cm, "agent_id": 7, + "tenant_id": "tenant", "name": "portable", "display_name": "Portable", + "description": "Test", "max_steps": 8, "provide_run_summary": False, + "enabled": True, "prompt": "Keep my instructions", "model_ids": [], + "tools": [], "managed_agents": [], + "context_policy": None, + } + + +@pytest.fixture +def boundary(monkeypatch): + monkeypatch.setattr(transfer, "query_sub_agents_id_list", lambda **_: []) + monkeypatch.setattr(transfer, "create_tool_config_list", AsyncMock(return_value=[])) + monkeypatch.setattr(transfer.skill_db, "query_skill_instances_by_agent_id", lambda **_: []) + monkeypatch.setattr(transfer, "_get_user_group_ids", lambda *_: "") + monkeypatch.setattr(transfer, "query_all_agent_info_by_tenant_id", lambda *_: []) + monkeypatch.setattr(transfer, "_resolve_model_ids_with_fallback", lambda **_: []) + monkeypatch.setattr(transfer, "query_all_tools", lambda **_: []) + monkeypatch.setattr(transfer, "publish_version_impl", lambda **_: None) + create = MagicMock(return_value={"agent_id": 42}) + monkeypatch.setattr(transfer, "create_agent", create) + return create + + +@pytest.mark.asyncio +@pytest.mark.parametrize("core", ["smolagents", "jiuwen"]) +@pytest.mark.parametrize("cm", [True, False]) +async def test_export_preserves_core_and_cm(boundary, monkeypatch, core, cm): + source = snapshot(core, cm) + monkeypatch.setattr(transfer, "search_agent_info_by_agent_id", lambda **_: source) + result = await transfer.export_agent_by_agent_id(7, "tenant", "user") + assert result.agent_core == core + assert result.enable_context_manager is cm + assert result.prompt == source["prompt"] + assert source == snapshot(core, cm) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("core", ["smolagents", "jiuwen"]) +@pytest.mark.parametrize("cm", [True, False]) +async def test_import_preserves_core_and_cm(boundary, core, cm): + portable = ExportAndImportAgentInfo(**snapshot(core, cm)) + result = await transfer.import_agent_by_agent_id(portable, "tenant", "user") + assert result == 42 + saved = boundary.call_args.kwargs["agent_info"] + assert saved["agent_core"] == core + assert saved["enable_context_manager"] is cm + assert saved["prompt"] == portable.prompt + + +def test_legacy_portable_defaults(): + fields = snapshot() + del fields["agent_core"] + del fields["enable_context_manager"] + portable = ExportAndImportAgentInfo(**fields) + assert portable.agent_core == "smolagents" + assert portable.enable_context_manager is True + + +@pytest.mark.parametrize("core", ["unknown", ""]) +def test_invalid_core_rejected(core): + with pytest.raises(ValidationError): + ExportAndImportAgentInfo(**snapshot(core)) + + +@pytest.mark.asyncio +async def test_import_failure_propagates(boundary): + boundary.side_effect = RuntimeError("database unavailable") + with pytest.raises(RuntimeError, match="database unavailable"): + await transfer.import_agent_by_agent_id(ExportAndImportAgentInfo(**snapshot()), "tenant", "user") From f5141bd7f4f0023fa2bb6b8d69c553f630db30fa Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 30 Oct 2026 18:48:13 +0800 Subject: [PATCH 15/26] test(jiuwen): align verification fixtures with shared runtime Update fixture authentication and unified prompt fields, include the controlled Python tool in native-mode expectations, and publish owned fixtures before checking their exports. Support explicit browser channel selection and distinguish resource diagnostic subsets from full passes. Include the existing delegation fix traceability record. Validation: model/native-file UT 71 passed; model bridge D3 4 passed; resource D3 6 passed and D5 4 passed; formal asset checks passed. --- .../test_runtime.py | 4 +- .../cases/JIUWEN-REAL-UI-D4-001/fixture.py | 6 +-- .../deployed_resources.py | 24 +++++++---- .../JIUWEN-SKILL-FILES-D4-001/fixture.py | 11 ++--- .../bugs/JIUWEN-DELEGATION-20261029.yaml | 42 +++++++++++++++++++ .../infra/automation/d4/playwright.config.ts | 2 +- 6 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 test-e2e/changes/bugs/JIUWEN-DELEGATION-20261029.yaml diff --git a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py index 41661aca82..e4ad9ea738 100644 --- a/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py +++ b/test-e2e/cases/JIUWEN-MODEL-BRIDGE-D3-001/test_runtime.py @@ -39,7 +39,9 @@ def test_factory_service_protocol_loop(monkeypatch, protocol, scenario): observations = json.dumps(state["requests"][1]["messages"]) assert "Tool execution error" not in observations if protocol == "native": - assert [tool["function"]["name"] for tool in state["requests"][0]["tools"]] == ["add"] + assert [tool["function"]["name"] for tool in state["requests"][0]["tools"]] == [ + "add", "nexent_execute_python", + ] assert any(item.get("tool_call_id") == "provider-call-1" and item["content"] == "5" for item in state["requests"][1]["messages"]) else: diff --git a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py index 4846664a81..7a81e5df3b 100644 --- a/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py +++ b/test-e2e/cases/JIUWEN-REAL-UI-D4-001/fixture.py @@ -12,7 +12,7 @@ import httpx from management.db.client import get_db_session, minio_client from management.db.models import AgentInfo, ConversationMessage, ConversationRecord -from management.services.permission.identity.authentication_helpers import generate_session_jwt +from gateway.authentication.auth import generate_session_jwt def digest(value): @@ -44,7 +44,7 @@ def main(): reference = next(f for f in files if f.get("type", "").startswith("image/")) draft = session.query(AgentInfo).filter_by(agent_id=source, version_no=0, delete_flag="N").one() published = session.query(AgentInfo).filter_by(agent_id=source, version_no=draft.current_version_no).one() - keys = ["business_logic_model_id", "model_ids", "duty_prompt", "constraint_prompt", "few_shots_prompt", + keys = ["business_logic_model_id", "model_ids", "prompt", "enable_context_manager", "context_policy", "max_steps", "requested_output_tokens", "provide_run_summary", "enable_protocol_repair_retry", "verification_config", "model_params_override"] values = {k: getattr(published, k) for k in keys} @@ -90,7 +90,7 @@ def main(): "release_note": "Owned real UI validation"})) cloned = checked(config.post("/agent/export", json={"agent_id": state["agent_id"]}))["data"] clone_info = cloned["agent_info"][str(state["agent_id"])] - for key in ["tools", "model_ids", "business_logic_model_id", "duty_prompt", "constraint_prompt", "few_shots_prompt"]: + for key in ["tools", "model_ids", "business_logic_model_id", "prompt"]: left, right = info.get(key), clone_info.get(key) if key == "tools": left = sorted([{k: v for k, v in t.items() if k != "agent_id"} for t in left], key=lambda t: t["name"]) diff --git a/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py index 2925158760..fe4dd03832 100644 --- a/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py +++ b/test-e2e/cases/JIUWEN-RESOURCES-D3-003/deployed_resources.py @@ -15,7 +15,7 @@ import httpx from management.db.client import get_db_session, minio_client from management.db.models import AgentInfo, AgentRelation, ConversationRecord -from management.services.permission.identity.authentication_helpers import ( +from gateway.authentication.auth import ( generate_session_jwt, ) @@ -117,7 +117,7 @@ def require_successful_workspace_script(events): def follow(agent, version, model, conversation, marker, rows, label): cid, events, final, later = run(agent, version, model, - "只根据本会话已完成的结果,回复上轮结果和验收编号。不要调用工具或子智能体,不要重复之前任务。", + "只根据本会话已完成的结果,回复上轮结果和上轮用户指定的验收编号(不要使用内部运行ID)。不要调用工具或子智能体,不要重复之前任务。", label + "_follow", conversation) assert cid == conversation and marker in final assert not [e for e in events if e.get("type") in {"tool", "subagent_start"}], "Follow-up replayed resources" @@ -157,7 +157,7 @@ def follow(agent, version, model, conversation, marker, rows, label): def skills(): marker = "SKILLS_" + directory.name cid, events, final, rows = run(child, child_version, inputs["child_model"], - f"帮我生成一个简单docx文件,正文包含{marker}。必须遵循docx Skill的JavaScript/docx库工作流,实际运行生成脚本,不要改用python-docx。实际生成并上传,回复文件链接和验收编号。", "skills") + f"本次用户指定的验收编号为{marker}。帮我生成一个简单docx文件,正文包含该编号。必须遵循docx Skill的JavaScript/docx库工作流,实际运行生成脚本,不要改用python-docx。实际生成并上传,回复上传工具原样返回的s3://文件链接和上述用户指定验收编号,不要使用内部运行ID或自造HTTP域名。", "skills") names = [e.get("tool_name") for e in events if e.get("type") == "tool"] assert "read_skill_md" in names and "nexent_execute_python" in names and "upload_to_s3" in names assert "download_from_s3" not in names @@ -169,7 +169,7 @@ def skills(): def mcp(): agent, (version, core, models) = clone(inputs["knowledge_source"], "mcp", "jiuwen", inputs["child_model"], - enabled_tool_ids=[inputs["mcp_tool_id"]], duty_prompt="Use the configured external MCP tool to answer actual current date queries.") + enabled_tool_ids=[inputs["mcp_tool_id"]], prompt="Use the configured external MCP tool to answer actual current date queries.") assert core == "jiuwen" marker = "MCP_" + directory.name cid, events, final, rows = run(agent, version, models[0], @@ -201,7 +201,7 @@ def knowledge(): def mixed(): agent, (version, core, models) = clone(inputs["knowledge_source"], "parent", "smolagents", inputs["parent_model"], enabled_tool_ids=[], related_agents=[{"agent_id": child, "version_no": child_version}], - duty_prompt="Delegate every document generation task to the configured file generation child. Report the actual child file link and result. Never generate the document yourself.") + prompt="Delegate every document generation task to the configured file generation child. Report the actual child file link and result. Never generate the document yourself.") with get_db_session() as session: relation = session.query(AgentRelation).filter_by(parent_agent_id=agent, version_no=version, delete_flag="N").one() assert relation.selected_agent_id == child and relation.selected_agent_version_no == child_version @@ -226,7 +226,14 @@ def mixed(): "child_docx_verified": True, "workspace_script_verified": True, "child_tools": names, "no_replay": True} - for label, execute in [("external_mcp", mcp), ("knowledge", knowledge), ("skills", skills), ("mixed_core", mixed)]: + available_scenarios = [("external_mcp", mcp), ("knowledge", knowledge), ("skills", skills), ("mixed_core", mixed)] + selected = inputs.get("diagnostic_scenarios", [label for label, _ in available_scenarios]) + assert selected and len(set(selected)) == len(selected) + assert set(selected).issubset({label for label, _ in available_scenarios}), "Unknown diagnostic scenario" + report["not_executed"] = [label for label, _ in available_scenarios if label not in selected] + for label, execute in available_scenarios: + if label not in selected: + continue try: report["scenarios"][label] = execute() except Exception as error: # noqa: BLE001 -- Retain each diagnostic failure and continue independent scenarios. @@ -274,8 +281,9 @@ def mixed(): if config: config.close() runtime.close() - report["status"] = "PASS" if (len(report["scenarios"]) == 4 and - all(row["completed"] for row in report["scenarios"].values()) and report.get("cleanup")) else "FAIL" + report["status"] = (("PASS" if len(report["scenarios"]) == 4 else "PARTIAL_PASS") + if (report["scenarios"] and all(row["completed"] for row in report["scenarios"].values()) + and report.get("cleanup")) else "FAIL") save() print("RESOURCE_RESULT=" + json.dumps(report), flush=True) return report["status"] == "PASS" diff --git a/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py index 91d181b903..153c3056d3 100644 --- a/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py +++ b/test-e2e/cases/JIUWEN-SKILL-FILES-D4-001/fixture.py @@ -13,7 +13,7 @@ import httpx from management.db.client import get_db_session, minio_client from management.db.models import AgentInfo, ConversationMessage, ConversationRecord -from management.services.permission.identity.authentication_helpers import generate_session_jwt +from gateway.authentication.auth import generate_session_jwt def digest(value): @@ -56,10 +56,10 @@ def main(): with get_db_session() as session: draft = session.query(AgentInfo).filter_by(agent_id=source, version_no=0, delete_flag="N").one() published = session.query(AgentInfo).filter_by(agent_id=source, version_no=draft.current_version_no).one() - assert published.agent_core == "jiuwen" + assert published.agent_core in {"smolagents", "jiuwen"}, "Unsupported source Core" originals = session.query(ConversationRecord).filter_by(agent_id=source, created_by=user).all() conversations = {row.conversation_id: history(row.conversation_id) for row in originals} - keys = ["model_ids", "business_logic_model_id", "duty_prompt", + keys = ["model_ids", "business_logic_model_id", "prompt", "enable_context_manager", "max_steps", "provide_run_summary", "verification_config", "enable_protocol_repair_retry", "requested_output_tokens", "context_policy", "model_params_override"] fields = {key: getattr(published, key) for key in keys} @@ -95,14 +95,15 @@ def main(): checked(config.post("/skills/instance/update", json={"agent_id": state["agent_id"], "skill_id": skill["skill_id"], "enabled": True, "version_no": 0, "config_values": skill.get("config_values") or {}})) + checked(config.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Skill-files-real"})) cloned = export_agent(config, state["agent_id"]) clone = cloned["agent_info"][str(state["agent_id"])] - for key in ["model_ids", "duty_prompt", "skill_names"]: + assert clone["agent_core"] == "jiuwen", "Owned target must use Jiuwen" + for key in ["model_ids", "prompt", "skill_names"]: assert clone[key] == entry[key], "Clone mismatch: " + key left = [{k: v for k, v in t.items() if k != "agent_id"} for t in entry["tools"]] right = [{k: v for k, v in t.items() if k != "agent_id"} for t in clone["tools"]] assert sorted(left, key=str) == sorted(right, key=str), "Clone tool mismatch" - checked(config.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Skill-files-real"})) print(json.dumps({"setup": True, "docx_skill": True, "equivalent_configuration": True})) elif mode == "select-model": model = int(sys.argv[3]) diff --git a/test-e2e/changes/bugs/JIUWEN-DELEGATION-20261029.yaml b/test-e2e/changes/bugs/JIUWEN-DELEGATION-20261029.yaml new file mode 100644 index 0000000000..7ee1114a72 --- /dev/null +++ b/test-e2e/changes/bugs/JIUWEN-DELEGATION-20261029.yaml @@ -0,0 +1,42 @@ +schema_version: '1.0' +change: + change_id: JIUWEN-DELEGATION-20261029 + change_type: bugfix + bug_id: JIUWEN-DELEGATION-20261029 + title: Honor explicit Jiuwen delegation before direct file execution + description: Preserve common orchestration instructions and constrain pending delegation + before dispatch; verify actual child files and live/restored common UI. + observed_behavior: The generic request to call a child generated a docx through root Python + and upload tools without a child event despite a saved worker binding. + expected_behavior: Explicit latest-request delegation executes an authorized child; + completed and live cards reflect actual child ownership without historical replay. + escaped_stage: production + coverage_gap: + type: missing_boundary + description: Prior child UI journey named the callable directly and inspected completion; + it did not detect bypass of a generic directive or require live nested visibility. + feature_contract_changed: true + affected_features: + existing: [JIUWEN-OUTPUT] + modified: [JIUWEN-RESOURCES] + added: [] + retired: [] + affected_cases: + existing: [] + modified: [JIUWEN-RESOURCES-D3-002, JIUWEN-RESOURCES-D5-003] + added: [JIUWEN-DELEGATION-D4-001] + retired: [] + traditional_ut: + decision: add + behaviors: + - Preserve authorized common orchestration while keeping code-action-only instructions excluded. + - Recognize affirmative current directives without promoting negation quotes explanations or history. + - Block direct native and text execution or premature final answers until actual authorized delegation. + - Bound correction once and retain cancellation deadline schema and child Core isolation. + reason: Unit boundaries complement existing controlled runtime and new real-model UI proof. + test_selectors: + - test/sdk/core/agents/test_jiuwen_delegation.py + - test/sdk/core/agents/test_jiuwen_model_service.py + - test/sdk/core/agents/test_subagent_wrapper.py + notes: Owning SPEC runtime-agent-core-factory/design.md JD-001 through JD-003. + Preserve existing D1 and models directories; no SQL or unrelated UI redesign. diff --git a/test-e2e/infra/automation/d4/playwright.config.ts b/test-e2e/infra/automation/d4/playwright.config.ts index 80f6870bd5..cd29511847 100644 --- a/test-e2e/infra/automation/d4/playwright.config.ts +++ b/test-e2e/infra/automation/d4/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ locale: "zh-CN", // Use the full Chromium binary rather than the headless shell: the shell // cancels blob-backed attachment downloads, which PW-FILE-01 exercises. - channel: "chromium", + channel: process.env.NEXENT_TEST_BROWSER_CHANNEL || "chromium", actionTimeout: Number(process.env.D4_ACTION_TIMEOUT_MS || "30000"), navigationTimeout: Number(process.env.D4_NAVIGATION_TIMEOUT_MS || "60000"), screenshot: "off", From 8b0bb3318fc25935ac3df83aaf1f6fc3be0693b5 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 30 Oct 2026 18:48:14 +0800 Subject: [PATCH 16/26] fix(chat): persist retry branches and restore saved history Authorize explicit saved-user retry targets and acknowledge their server identity without appending a new question. Keep history siblings ordered, project the latest answer into model context, and share running-branch recovery across chat and Workbench without creating an extra branch. Include required retry helpers, boundary regressions and fixed browser acceptance assets. Independently repeated equal-text questions retain their own persisted boundaries. Validation: backend UT 34 and frontend UT 35 passed; type-check passed; UT-FE-040 passed all 5 component tests; formal asset checks passed. Real browser rerun was blocked by model_authentication_error on the first model call. Owned fixtures were cleaned and the source stayed unchanged; this rerun is not a real-model acceptance pass. --- backend/contracts/agent.py | 6 + .../management/db/conversation/repository.py | 43 ++- .../management/services/agent/subscription.py | 5 + .../services/conversation/service.py | 11 +- .../conversation-thread-list-adapter.tsx | 52 +-- .../adapter/remote-chat-model-adapter.ts | 55 ++- .../[locale]/newchat/assistant-ui/thread.tsx | 37 +- frontend/app/[locale]/newchat/page.tsx | 81 +--- .../workbench/hooks/useConversationResume.ts | 128 +------ frontend/hooks/chat/useConversationResume.ts | 170 +++++++++ frontend/lib/conversationRetry.ts | 60 +++ frontend/services/conversationService.ts | 30 +- .../workbench/ConversationResume.test.tsx | 217 ++++++++--- .../ConversationResumeBranch.test.tsx | 168 +++++++++ .../ConversationRetryIdentity.test.ts | 51 +++ frontend/tests/workbench/WorkbenchApi.test.ts | 63 ++++ .../cases/CHAT-RETRY-HISTORY-D4-001/case.yaml | 52 +++ .../CHAT-RETRY-HISTORY-D4-001/execution.yaml | 16 + .../CHAT-RETRY-HISTORY-D4-001/fixture.py | 30 ++ .../CHAT-RETRY-HISTORY-D4-001/test.spec.ts | 354 ++++++++++++++++++ test-e2e/cases/UT-FE-040/case.yaml | 87 +++-- test-e2e/cases/UT-FE-040/execution.yaml | 16 +- test-e2e/cases/UT-FE-040/test.test.tsx | 242 +++++++++++- .../bugs/CHAT-RETRY-PERSISTENCE-20261029.yaml | 47 +++ .../bugs/CHAT-RETRY-RESUME-20261020.yaml | 52 +++ test-e2e/features/F-179/cases.md | 1 + test-e2e/features/F-179/feature.yaml | 3 + .../automation/d1/frontend/vitest.config.ts | 1 + .../test_conversation_append_order.py | 32 ++ .../test_conversation_history_stopped.py | 20 +- .../test_conversation_runtime_boundary.py | 27 ++ 31 files changed, 1782 insertions(+), 375 deletions(-) create mode 100644 frontend/hooks/chat/useConversationResume.ts create mode 100644 frontend/lib/conversationRetry.ts create mode 100644 frontend/tests/workbench/ConversationResumeBranch.test.tsx create mode 100644 frontend/tests/workbench/ConversationRetryIdentity.test.ts create mode 100644 test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/case.yaml create mode 100644 test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/execution.yaml create mode 100644 test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/fixture.py create mode 100644 test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/test.spec.ts create mode 100644 test-e2e/changes/bugs/CHAT-RETRY-PERSISTENCE-20261029.yaml create mode 100644 test-e2e/changes/bugs/CHAT-RETRY-RESUME-20261020.yaml diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index 59c398513a..eb3e7a9c59 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -217,6 +217,8 @@ class AgentRequest(BaseModel): clarification_response: Optional[ClarificationResponse] = None conversation_id: Optional[int] = None history: Optional[List[HistoryItem]] = None + retry_user_message_id: Optional[int] = Field(default=None, gt=0, strict=True) + retry_message_index: Optional[int] = Field(default=None, ge=0, strict=True) # Complete list of attachment information minio_files: Optional[List[Dict[str, Any]]] = None agent_id: Optional[int] = None @@ -273,6 +275,10 @@ def validate_context_policy(cls, value): @model_validator(mode="after") def validate_workbench_entrypoint(self): + if self.retry_message_index is not None and self.retry_user_message_id is None: + raise ValueError("Retry requires an original user message ID") + if self.retry_user_message_id is not None and (not self.conversation_id or self.is_debug): + raise ValueError("Retry requires a persisted conversation") if ( self.entrypoint == "workbench" and self.workbench is None diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index cfa89c35c1..044dc7a538 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -356,6 +356,45 @@ def append_conversation_user_message( return message_id, index +def resolve_conversation_retry_user( + conversation_id: int, user_id: str, message_id: int, message_index: Optional[int], + content: str, minio_files, +) -> tuple[int, int]: + """Authorize a retry boundary without appending another user turn.""" + def file_keys(value): + if isinstance(value, str): + value = json.loads(value) + return [item.get("object_name") or item.get("url") or item.get("name") + if isinstance(item, dict) else item for item in (value or [])] + + with get_db_session() as session: + _lock_conversation_limit_scope(session, f"conversation-turn-limit:{conversation_id}") + owned = session.scalar(select(ConversationRecord.conversation_id).where( + ConversationRecord.conversation_id == conversation_id, + ConversationRecord.created_by == user_id, ConversationRecord.delete_flag == "N", + )) + if owned is None: + raise ConversationNotFoundError("Conversation not found") + target = session.scalar(select(ConversationMessage).where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.message_id == message_id, + ConversationMessage.created_by == user_id, + ConversationMessage.message_role == "user", ConversationMessage.delete_flag == "N", + )) + if (target is None or target.message_index is None or target.message_index < 0 + or (message_index is not None and target.message_index != message_index) + or target.message_content != content or file_keys(target.minio_files) != file_keys(minio_files)): + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Invalid retry target") + answer = session.scalar(select(ConversationMessage.message_id).where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.message_index == target.message_index + 1, + ConversationMessage.message_role == "assistant", ConversationMessage.delete_flag == "N", + ).limit(1)) + if answer is None: + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Retry target has no answer") + return target.message_id, target.message_index + + def _resolve_clarification_response(session, conversation_id, user_id, role, raw): """Resolve an owned latest form before writing either side of the response.""" from contracts.clarification import ClarificationResponse, normalize_form @@ -1064,7 +1103,7 @@ def get_conversation_messages(conversation_id: int) -> List[Dict[str, Any]]: stmt = ( select(ConversationMessage) .where(ConversationMessage.conversation_id == conversation_id, ConversationMessage.delete_flag == "N") - .order_by(asc(ConversationMessage.message_index)) + .order_by(asc(ConversationMessage.message_index), asc(ConversationMessage.message_id)) ) # Execute the query @@ -2688,7 +2727,7 @@ def get_historical_context( ConversationMessage.delete_flag == "N", ConversationMessage.message_role.in_(["user", "assistant"]), ) - .order_by(asc(ConversationMessage.message_index)) + .order_by(asc(ConversationMessage.message_index), desc(ConversationMessage.message_id)) ).all() turns: List[Dict[str, Any]] = [] diff --git a/backend/management/services/agent/subscription.py b/backend/management/services/agent/subscription.py index 722b566e0c..376bf849c4 100644 --- a/backend/management/services/agent/subscription.py +++ b/backend/management/services/agent/subscription.py @@ -1027,6 +1027,11 @@ async def stream_with_agent_context(): headers["run_id"] = debug_run_id if agent_request.conversation_id is not None: headers["conversation_id"] = str(agent_request.conversation_id) + for field, header in [("_runtime_user_message_id", "X-User-Message-Id"), + ("_runtime_user_message_index", "X-User-Message-Index")]: + value = getattr(agent_request, field, None) + if type(value) is int and value >= 0: + headers[header] = str(value) runtime_metadata_version = getattr(agent_request, "_runtime_metadata_version", None) if runtime_metadata_version is not None: headers["X-Runtime-Metadata-Version"] = str(runtime_metadata_version) diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index 51063e5aa4..3ab8647c0b 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -23,7 +23,7 @@ from contracts.constants.constants import CHAT_MODE_VALUES from management.db.conversation.repository import create_conversation, create_conversation_message, create_message_unit, create_source_image, create_source_search, delete_conversation, delete_conversations_batch, get_conversation, get_conversation_history, get_historical_context, get_latest_assistant_message, get_latest_assistant_message_id, get_latest_user_message_id, get_last_unit_for_message, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, persist_assistant_run_batch as persist_assistant_run_batch_db, rename_conversation, save_history_summary, update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, replace_conversation_workbench_config, replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, update_message_opinion, update_message_unit_content, update_message_unit_status from management.db.model.repository import get_model_by_model_id -from management.db.conversation.repository import append_conversation_user_message +from management.db.conversation.repository import append_conversation_user_message, resolve_conversation_retry_user from management.services.model.adapter import get_llm_adapter_from_config from management.services.configuration.tenant.settings import tenant_config_manager from nexent.core.agents.prompt.auxiliary import compose_auxiliary_prompt @@ -257,6 +257,15 @@ def save_conversation_user(request: AgentRequest, user_id: str, tenant_id: str) User messages do not need unit records. """ source = MessageSource(getattr(request, "_message_source", MessageSource.CHAT)) + retry_id = getattr(request, "retry_user_message_id", None) + if type(retry_id) is int: + message_id, message_index = resolve_conversation_retry_user( + request.conversation_id, user_id, retry_id, request.retry_message_index, + strip_current_time_prefix(request.query), request.minio_files, + ) + request._runtime_user_message_id = message_id + request._runtime_user_message_index = message_index + return message_id, message_index = append_conversation_user_message( request.conversation_id, strip_current_time_prefix(request.query), request.minio_files, user_id, **({"message_source": source} if source != MessageSource.CHAT else {}), diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 8e2b83c61a..3d5b4feeee 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -14,8 +14,6 @@ import { useAui, } from "@assistant-ui/react"; import type { - ChatModelRunOptions, - ChatModelRunResult, CompleteAttachment, ExportedMessageRepositoryItem, GenericThreadHistoryAdapter, @@ -62,7 +60,6 @@ import { searchImagesRegistry, isReasoningChunkType, skillFileUploadsRegistry, - remoteChatModelAdapter, parseStepTokenCount, parsePlan, parsePlanStepUpdate, @@ -1391,7 +1388,11 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { // An NL2Agent turn may consist only of an interactive card boundary. // Keep the message so its restored metadata can render the card. - if (historicalNl2a && content.length === 0) { + if ( + (historicalNl2a || + (msg.role === "assistant" && msg.status === "streaming")) && + content.length === 0 + ) { content.push({ type: "text", text: "" }); } @@ -1432,7 +1433,16 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { id: messageId, role: msg.role, content, - ...(restoredRuntimeStatus ? {status: restoredRuntimeStatus} : {}), + // A persisted running flag does not represent an attached local stream. + // The shared resume hook reconnects it under the original user boundary. + ...(restoredRuntimeStatus + ? { + status: + restoredRuntimeStatus.type === "running" + ? { type: "complete" as const, reason: "unknown" as const } + : restoredRuntimeStatus, + } + : {}), ...(createdAt ? { createdAt } : {}), ...(msg.role === "user" && attachments.length > 0 ? { attachments } @@ -1456,41 +1466,9 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { } return { ...repository, - unstable_resume: detail.streaming_message?.status === "streaming", }; } - async *resume( - options: ChatModelRunOptions - ): AsyncGenerator { - const remoteId = this.getRemoteId(); - if (!remoteId) { - log.warn( - "[history-adapter] Cannot resume without a remote conversation ID" - ); - return; - } - - const custom = (options.runConfig?.custom ?? {}) as Record; - const resumedRun = remoteChatModelAdapter.run({ - ...options, - runConfig: { - ...options.runConfig, - custom: { - ...custom, - threadId: remoteId, - resume: true, - }, - }, - }); - - if (Symbol.asyncIterator in resumedRun) { - yield* resumedRun; - return; - } - yield await resumedRun; - } - // `append` is intentionally a no-op: in the remote-thread-list flow, message // persistence is owned by the `runAgent` stream endpoint and message history // is reloaded via `load()`. Hooking `append` here would prematurely persist diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 45617ebd11..a95a0c2828 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -25,6 +25,7 @@ import { createWorkbenchAgentDraft } from "@/features/workbench/agentCreationDra import { ApiError } from "@/services/api"; import { notifyWorkbenchConfigResolved } from "@/features/workbench/runtimeEvents"; import log from "@/lib/logger"; +import { acknowledgeConversationUser } from "@/lib/conversationRetry"; import { adaptNexentOutputStream } from "@/lib/nexentOutputAdapter"; import { completeTrailingToolCalls } from "@/lib/toolCallStatus"; import type { SourceAttributes } from "@/types/source"; @@ -475,7 +476,6 @@ export interface VerificationPanelPart { // Accumulated total duration across all steps - /** * Parses a backend `token_count` payload into a `StepTokenCount` entry. * Returns null when the payload is malformed so callers can skip silently. @@ -1007,7 +1007,11 @@ export function attachExecutionLogsToTool( targetToolCall.result = (targetToolCall.result ?? "") + chunk.content; if (chunk.is_error) { targetToolCall.isError = true; - targetToolCall.status = { type: "incomplete", reason: "error", error: chunk.content }; + targetToolCall.status = { + type: "incomplete", + reason: "error", + error: chunk.content, + }; } return true; } @@ -1276,7 +1280,11 @@ export function parseVerification(chunk: { * collapsible card flips from its running indicator to a finished state * without needing a separate per-message registry. */ -export function markSubAgentRunFinished(parts: any[], runId: string, executionStatus?: "completed" | "failed" | "stopped"): void { +export function markSubAgentRunFinished( + parts: any[], + runId: string, + executionStatus?: "completed" | "failed" | "stopped" +): void { for (const part of parts) { if (part && part.metadata && part.metadata.runId === runId) { part.metadata.isRunning = false; @@ -1378,7 +1386,10 @@ function buildTimingFromTokenCount( runSteps.push(parsed); pushStepTokenCount(parsed); - const totalDuration = runSteps.reduce((total, step) => total + step.duration, 0); + const totalDuration = runSteps.reduce( + (total, step) => total + step.duration, + 0 + ); return buildTimingResult( Date.now(), // streamStartTime - approximate @@ -1780,12 +1791,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { : undefined, persist_history: isEphemeralRuntime && custom?.persistCreationHistory === true, - retry_message_index: isEphemeralRuntime - ? custom?.retry_message_index - : undefined, - retry_user_message_id: isEphemeralRuntime - ? custom?.retry_user_message_id - : undefined, + retry_message_index: custom?.retry_message_index, + retry_user_message_id: custom?.retry_user_message_id, workbench_config: isEphemeralRuntime ? creationConfig : undefined, draft_snapshot: isNl2Skill ? custom?.draftSnapshot : undefined, language: isNl2Skill ? custom?.language : undefined, @@ -1827,6 +1834,16 @@ export const remoteChatModelAdapter: ChatModelAdapter = { }, (version) => { if (!userAborted) custom?.onWorkbenchConfigVersion?.(version); + }, + (id, index) => { + if ( + !userAborted && + !isResume && + !isEphemeralRuntime && + lastUserIndex >= 0 + ) { + acknowledgeConversationUser(messages[lastUserIndex].id, id, index); + } } ); if (userAborted) { @@ -2422,7 +2439,12 @@ export const remoteChatModelAdapter: ChatModelAdapter = { ) ), ...(output.adapter.status ? { status: output.adapter.status } : {}), - metadata: { custom: { ...(nl2a ? { nl2a } : {}), stepTokenCounts: [...runStepTokenCounts] } }, + metadata: { + custom: { + ...(nl2a ? { nl2a } : {}), + stepTokenCounts: [...runStepTokenCounts], + }, + }, }); const streamStartTime = Date.now(); @@ -2525,7 +2547,10 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Handle token_count - store timing for final yield if (chunk.type === "token_count") { - storedTiming = buildTimingFromTokenCount(chunk.content, runStepTokenCounts); + storedTiming = buildTimingFromTokenCount( + chunk.content, + runStepTokenCounts + ); continue; // Don't yield for internal data chunks } @@ -2770,7 +2795,11 @@ export const remoteChatModelAdapter: ChatModelAdapter = { flushOpenReasoning(closing.invocationId); } activeSubAgents.delete(invocationId!); - markSubAgentRunFinished(contentParts, closing.runId, payload.status); + markSubAgentRunFinished( + contentParts, + closing.runId, + payload.status + ); } // Pick the next active invocation id so subsequent reasoning / // tool parts default to a still-open sibling when present. diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 7f5d03ddd0..2726b42f64 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -105,6 +105,7 @@ import { DirectiveText, SkillDirectiveText } from "../ui/directive-text"; import { QuoteBlock } from "../ui/quote"; import { BranchPicker } from "../ui/branch-picker"; import { getCreationRetryTarget } from "@/features/workbench/creationRetry"; +import { getConversationRetryTarget } from "@/lib/conversationRetry"; import { DotMatrix } from "../ui/dot-matrix"; import { MessageTiming } from "../ui/message-timing"; import { SingleTurnTokenUsage } from "../ui/token-usage"; @@ -1641,14 +1642,14 @@ const AssistantActionBar: FC = () => { const runConfig = aui.thread.composer().getState().runConfig; const custom = runConfig.custom as { runtimeMode?: string; [key: string]: unknown } | undefined; - if ( - custom?.runtimeMode !== "nl2skill" && - custom?.runtimeMode !== "nl2agent" - ) { + if (custom?.runtimeMode === "agent-debug") { aui.message.reload(); return; } - const target = getCreationRetryTarget( + const resolveTarget = creationMode + ? getCreationRetryTarget + : getConversationRetryTarget; + const target = resolveTarget( aui.thread.getState().messages, aui.message.getState().parentId ); @@ -1678,21 +1679,13 @@ const AssistantActionBar: FC = () => { - {creationMode ? ( - - - - ) : ( - - - - - - )} + + + content[index]?.metadata?.executionStatus).find(status => status === "failed" || status === "stopped")} + executionStatus={indices + .map((index) => content[index]?.metadata?.executionStatus) + .find((status) => status === "failed" || status === "stopped")} agentName={descriptor.agentName} depth={descriptor.depth} isRunning={descriptor.isRunning} diff --git a/frontend/app/[locale]/newchat/page.tsx b/frontend/app/[locale]/newchat/page.tsx index 6736b9e4b9..4b5cc387f3 100644 --- a/frontend/app/[locale]/newchat/page.tsx +++ b/frontend/app/[locale]/newchat/page.tsx @@ -69,6 +69,7 @@ import { useConfig } from "@/hooks/useConfig"; import { ServerDictationAdapter } from "./adapter/server-dictation-adapter"; import type { STTModelConfig } from "@/types/modelConfig"; import { conversationService } from "@/services/conversationService"; +import { useConversationResume } from "@/hooks/chat/useConversationResume"; import { useQueryClient } from "@tanstack/react-query"; import { getWorkbenchPinStorageKey } from "./assistant-ui/thread-pins"; import { @@ -864,85 +865,17 @@ const HomeContent: FC<{ restoreHistoricalChatMode(conversationId); }, [activeConversationId, activeThreadId]); - // A route change tears down the local stream, while the backend keeps the - // conversation marked as streaming. Reconnect the assistant-ui runtime when - // the historical load reports that state. - useEffect(() => { - const numericConversationId = Number(activeConversationId); - if ( - !ready || - isThreadRunning || - !activeThreadId || - !Number.isInteger(numericConversationId) || - numericConversationId <= 0 || - resumedConversationIdsRef.current.has(numericConversationId) - ) { - return; - } - - let cancelled = false; - void conversationService - .getById(String(numericConversationId)) - .then((conversation) => { - if ( - cancelled || - conversation.streaming_message?.status !== "streaming" || - resumedConversationIdsRef.current.has(numericConversationId) - ) { - return; - } - - resumedConversationIdsRef.current.add(numericConversationId); - const messages = runtime.thread.getState().messages; - const parentId = messages.at(-1)?.id ?? null; - runtime.thread.resumeRun({ - parentId, - sourceId: null, - runConfig: { - custom: { - threadId: String(numericConversationId), - ...(selectedAgent?.id ? { agentId: selectedAgent.id } : {}), - ...(selectedAgent?.current_version_no - ? { agentVersionNo: selectedAgent.current_version_no } - : {}), - onGenerationStopped: handleGenerationStopped, - enablePlan: chatMode === "planning", - resume: true, - }, - }, - stream: async function* (options) { - const resumedRun = remoteChatModelAdapter.run(options); - if (Symbol.asyncIterator in resumedRun) { - yield* resumedRun; - } else { - yield await resumedRun; - } - }, - }); - }) - .catch((error) => { - if (!cancelled) { - resumedConversationIdsRef.current.delete(numericConversationId); - log.error( - `[HomeContent] Failed to resume conversation ${numericConversationId}:`, - error - ); - } - }); - - return () => { - cancelled = true; - }; - }, [ + useConversationResume({ + runtime, activeConversationId, activeThreadId, - chatMode, - isThreadRunning, ready, - runtime, + isThreadRunning, selectedAgent, + chatMode, + resumedConversationIdsRef, handleGenerationStopped, - ]); + }); // Publish the server conversation id registry to the thread-list adapter so // `generateTitle` can wait for the real backend id before issuing its diff --git a/frontend/features/workbench/hooks/useConversationResume.ts b/frontend/features/workbench/hooks/useConversationResume.ts index bc814edb81..78da07a323 100644 --- a/frontend/features/workbench/hooks/useConversationResume.ts +++ b/frontend/features/workbench/hooks/useConversationResume.ts @@ -1,129 +1,3 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"use client"; - -import { useEffect, useRef, type RefObject } from "react"; -import type { AssistantRuntime } from "@assistant-ui/react"; -import type { Agent } from "@/types/agentConfig"; -import { conversationService } from "@/services/conversationService"; -import type { ConversationDetailReader } from "@/types/conversation"; -import { remoteChatModelAdapter } from "@/app/newchat/adapter/remote-chat-model-adapter"; -import log from "@/lib/logger"; - -export function useConversationResume({ - runtime, - activeConversationId, - activeThreadId, - ready, - isThreadRunning, - selectedAgent, - chatMode, - resumedConversationIdsRef, - handleGenerationStopped, - loadDetail = conversationService.getById, - invalidateDetail, -}: { - runtime: AssistantRuntime; - activeConversationId: string | undefined; - activeThreadId: string | undefined; - ready: boolean; - isThreadRunning: boolean; - selectedAgent: Agent | null; - chatMode: "planning" | "execution"; - resumedConversationIdsRef: RefObject>; - handleGenerationStopped: (id: number) => void; - loadDetail?: ConversationDetailReader; - invalidateDetail?: (id: string) => void; -}) { - const pendingResumes = useRef(new Set()); - // A route change tears down the local stream, while the backend keeps the - // conversation marked as streaming. Reconnect the assistant-ui runtime when - // the historical load reports that state. - useEffect(() => { - const pending = pendingResumes.current; - const numericConversationId = Number(activeConversationId); - if (isThreadRunning) { - resumedConversationIdsRef.current?.delete(numericConversationId); - if (numericConversationId > 0) - invalidateDetail?.(String(numericConversationId)); - return; - } - if ( - !ready || - isThreadRunning || - !activeThreadId || - !Number.isInteger(numericConversationId) || - numericConversationId <= 0 || - resumedConversationIdsRef.current!.has(numericConversationId) || - pending.has(numericConversationId) - ) { - return; - } - - let cancelled = false; - void loadDetail(String(numericConversationId)) - .then((conversation) => { - if ( - cancelled || - conversation.streaming_message?.status !== "streaming" || - resumedConversationIdsRef.current!.has(numericConversationId) - ) { - return; - } - - pending.add(numericConversationId); - const messages = runtime.thread.getState().messages; - const parentId = messages.at(-1)?.id ?? null; - runtime.thread.resumeRun({ - parentId, - sourceId: null, - runConfig: { - custom: { - threadId: String(numericConversationId), - ...(selectedAgent?.id ? { agentId: selectedAgent.id } : {}), - ...(selectedAgent?.current_version_no - ? { agentVersionNo: selectedAgent.current_version_no } - : {}), - onGenerationStopped: handleGenerationStopped, - enablePlan: chatMode === "planning", - resume: true, - }, - }, - stream: async function* (options) { - const resumedRun = remoteChatModelAdapter.run(options); - if (Symbol.asyncIterator in resumedRun) { - yield* resumedRun; - } else { - yield await resumedRun; - } - }, - }); - }) - .catch((error) => { - if (!cancelled) { - pending.delete(numericConversationId); - log.error( - `[HomeContent] Failed to resume conversation ${numericConversationId}:`, - error - ); - } - }); - - return () => { - cancelled = true; - pending.delete(numericConversationId); - }; - }, [ - activeConversationId, - activeThreadId, - chatMode, - isThreadRunning, - ready, - runtime, - selectedAgent, - handleGenerationStopped, - resumedConversationIdsRef, - loadDetail, - invalidateDetail, - ]); -} +export { useConversationResume } from "@/hooks/chat/useConversationResume"; diff --git a/frontend/hooks/chat/useConversationResume.ts b/frontend/hooks/chat/useConversationResume.ts new file mode 100644 index 0000000000..afef54f2a5 --- /dev/null +++ b/frontend/hooks/chat/useConversationResume.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; +import type { AssistantRuntime } from "@assistant-ui/react"; +import type { ConversationDetailReader } from "@/types/conversation"; +import type { Agent } from "@/types/agentConfig"; +import { conversationService } from "@/services/conversationService"; +import { remoteChatModelAdapter } from "@/app/newchat/adapter/remote-chat-model-adapter"; +import log from "@/lib/logger"; +import { RemoteConversationHistoryAdapter } from "@/app/newchat/adapter/conversation-thread-list-adapter"; + +export function useConversationResume({ + runtime, + activeConversationId, + activeThreadId, + ready, + isThreadRunning, + selectedAgent, + chatMode, + resumedConversationIdsRef, + handleGenerationStopped, + loadDetail = conversationService.getById, + invalidateDetail, +}: { + runtime: AssistantRuntime; + activeConversationId: string | undefined; + activeThreadId: string | undefined; + ready: boolean; + isThreadRunning: boolean; + selectedAgent: Agent | null; + chatMode: "planning" | "execution"; + resumedConversationIdsRef: RefObject>; + handleGenerationStopped: (id: number) => void; + loadDetail?: ConversationDetailReader; + invalidateDetail?: (id: string) => void; +}) { + const pendingResumes = useRef(new Set()); + // A route change tears down the local stream, while the backend keeps the + // conversation marked as streaming. Reconnect the assistant-ui runtime when + // the historical load reports that state. + useEffect(() => { + const pending = pendingResumes.current; + const numericConversationId = Number(activeConversationId); + if (isThreadRunning) { + resumedConversationIdsRef.current?.delete(numericConversationId); + if (numericConversationId > 0) + invalidateDetail?.(String(numericConversationId)); + return; + } + if ( + !ready || + isThreadRunning || + !activeThreadId || + !Number.isInteger(numericConversationId) || + numericConversationId <= 0 || + resumedConversationIdsRef.current!.has(numericConversationId) || + pending.has(numericConversationId) + ) { + return; + } + + let cancelled = false; + void loadDetail(String(numericConversationId)) + .then(async (conversation) => { + if ( + cancelled || + conversation.streaming_message?.status !== "streaming" || + resumedConversationIdsRef.current!.has(numericConversationId) + ) { + return; + } + + pending.add(numericConversationId); + const remoteId = String(numericConversationId); + const history = new RemoteConversationHistoryAdapter( + () => remoteId, + async () => ({ remoteId }), + async () => conversation + ); + const repository = await history.load(); + if (cancelled || runtime.thread.getState().isRunning) return; + const streamingId = String(conversation.streaming_message.message_id); + const streaming = repository.messages.find( + (entry) => entry.message.id === streamingId + ); + const parent = repository.messages.find( + (entry) => entry.message.id === streaming?.parentId + ); + if ( + streaming?.message.role !== "assistant" || + parent?.message.role !== "user" + ) { + throw new Error("Streaming answer has no originating user message"); + } + const streamingMessage = streaming.message; + const parentId = parent.message.id; + // resumeRun creates a new assistant. Replace only the persisted running + // branch so it remains a sibling of earlier retries, never their child. + runtime.thread.import({ + ...repository, + messages: repository.messages.filter( + (entry) => entry.message.id !== streamingId + ), + // Importing with the user as head would prune all its answer branches. + headId: + repository.messages.findLast( + (entry) => + entry.parentId === parentId && entry.message.id !== streamingId + )?.message.id ?? parentId, + }); + runtime.thread.resumeRun({ + parentId, + sourceId: null, + runConfig: { + custom: { + threadId: String(numericConversationId), + ...(selectedAgent?.id ? { agentId: selectedAgent.id } : {}), + ...(selectedAgent?.current_version_no + ? { agentVersionNo: selectedAgent.current_version_no } + : {}), + onGenerationStopped: handleGenerationStopped, + enablePlan: chatMode === "planning", + resume: true, + }, + }, + stream: async function* (options) { + // Preserve the persisted output until the server replays the stream. + yield { + content: streamingMessage.content, + metadata: streamingMessage.metadata, + }; + const resumedRun = remoteChatModelAdapter.run(options); + if (Symbol.asyncIterator in resumedRun) { + yield* resumedRun; + } else { + yield await resumedRun; + } + }, + }); + }) + .catch((error) => { + if (!cancelled) { + pending.delete(numericConversationId); + log.error( + `[HomeContent] Failed to resume conversation ${numericConversationId}:`, + error + ); + } + }); + + return () => { + cancelled = true; + pending.delete(numericConversationId); + }; + }, [ + activeConversationId, + activeThreadId, + chatMode, + isThreadRunning, + ready, + runtime, + selectedAgent, + handleGenerationStopped, + resumedConversationIdsRef, + loadDetail, + invalidateDetail, + ]); +} diff --git a/frontend/lib/conversationRetry.ts b/frontend/lib/conversationRetry.ts new file mode 100644 index 0000000000..962a01bb25 --- /dev/null +++ b/frontend/lib/conversationRetry.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +type UserBoundary = { + retry_user_message_id: number; + retry_message_index?: number; +}; +type RetryMessage = { + id: string; + role: string; + metadata?: { custom?: Record }; +}; + +// Store only acknowledged server identities, never question text or credentials. +const savedUsers = new Map(); + +export function acknowledgeConversationUser( + localId: string, + id: number, + index: number +) { + if ( + !Number.isSafeInteger(id) || + id <= 0 || + !Number.isSafeInteger(index) || + index < 0 + ) + return; + savedUsers.delete(localId); + savedUsers.set(localId, { + retry_user_message_id: id, + retry_message_index: index, + }); + if (savedUsers.size > 1000) + savedUsers.delete(savedUsers.keys().next().value!); +} + +export function getConversationRetryTarget( + messages: readonly RetryMessage[], + parentId: string | null +): UserBoundary | null { + const user = messages.find( + (item) => item.id === parentId && item.role === "user" + ); + if (!user) return null; + const live = savedUsers.get(user.id); + if (live) return live; + if ( + !/^\d+$/.test(user.id) || + !Number.isSafeInteger(Number(user.id)) || + Number(user.id) <= 0 + ) + return null; + const index = user.metadata?.custom?.historicalMessageIndex; + return { + retry_user_message_id: Number(user.id), + ...(typeof index === "number" && Number.isInteger(index) && index >= 0 + ? { retry_message_index: index } + : {}), + }; +} diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index 2bc390d225..3f66e7d50a 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -1134,7 +1134,8 @@ export const conversationService = { onConversationId?: (id: string) => void, onRuntimeMetadataVersion?: (version: number) => void, onRunId?: (id: string) => void, - onWorkbenchConfigVersion?: (version: number) => void + onWorkbenchConfigVersion?: (version: number) => void, + onUserMessage?: (id: number, index: number) => void ): Promise< ReadableStreamDefaultReader | { type: "json"; data: unknown } > { @@ -1162,14 +1163,13 @@ export const conversationService = { if (params.persist_history && params.runtime_mode) { requestParams.persist_history = true; requestParams.workbench_config = params.workbench_config; - if (params.retry_message_index !== undefined) { - requestParams.retry_message_index = params.retry_message_index; - } - if (params.retry_user_message_id !== undefined) { - requestParams.retry_user_message_id = params.retry_user_message_id; - } } - + if (params.retry_message_index !== undefined) { + requestParams.retry_message_index = params.retry_message_index; + } + if (params.retry_user_message_id !== undefined) { + requestParams.retry_user_message_id = params.retry_user_message_id; + } // Only include conversation_id if it has a value if ( params.conversation_id !== undefined && @@ -1263,6 +1263,20 @@ export const conversationService = { onConversationId(conversationId); } const runId = response.headers.get("run_id"); + const userId = response.headers.get("X-User-Message-Id"); + const userIndex = response.headers.get("X-User-Message-Index"); + if (response.ok && userId !== null && userIndex !== null) { + const id = Number(userId), + index = Number(userIndex); + if ( + Number.isSafeInteger(id) && + id > 0 && + Number.isSafeInteger(index) && + index >= 0 + ) { + onUserMessage?.(id, index); + } + } if (runId && onRunId) { onRunId(runId); } diff --git a/frontend/tests/workbench/ConversationResume.test.tsx b/frontend/tests/workbench/ConversationResume.test.tsx index d180588ea9..cc3d1fbf70 100644 --- a/frontend/tests/workbench/ConversationResume.test.tsx +++ b/frontend/tests/workbench/ConversationResume.test.tsx @@ -2,59 +2,35 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { expect, it, vi } from "vitest"; -import type { AssistantRuntime } from "@assistant-ui/react"; -import { useConversationResume } from "@/features/workbench/hooks/useConversationResume"; import { createConversationDetailLoader } from "@/lib/conversationDetails"; import { QueryClient } from "@tanstack/react-query"; +import type { AssistantRuntime } from "@assistant-ui/react"; +import { useConversationResume } from "@/features/workbench/hooks/useConversationResume"; const service = vi.hoisted(() => ({ getById: vi.fn() })); vi.mock("@/services/conversationService", () => ({ + CONVERSATION_PAGE_SIZE: 20, conversationService: service, })); -vi.mock("@/app/newchat/adapter/remote-chat-model-adapter", () => ({ - remoteChatModelAdapter: { run: vi.fn() }, -})); -vi.mock("@/lib/logger", () => ({ default: { error: vi.fn() } })); - -it.each(["completed", "streaming"])( - "uses shared %s restore state, then reads fresh state after a live run without replaying", - async (status) => { - service.getById - .mockReset() - .mockResolvedValueOnce({ streaming_message: { status } }) - .mockResolvedValue({ streaming_message: { status: "completed" } }); - const client = new QueryClient(); - const details = createConversationDetailLoader(client, "resume-test"); - await details.load("12"); - const resume = vi.fn(); - const props = { - runtime: { - thread: { getState: () => ({ messages: [] }), resumeRun: resume }, - } as unknown as AssistantRuntime, - activeConversationId: "12", - activeThreadId: "a", - ready: true, - isThreadRunning: false, - selectedAgent: null, - chatMode: "execution" as const, - resumedConversationIdsRef: { current: new Set() }, - handleGenerationStopped: vi.fn(), - loadDetail: details.load, - invalidateDetail: details.invalidate, - }; - const { rerender, unmount } = renderHook(useConversationResume, { - initialProps: props, - }); - await act(async () => {}); - expect(service.getById).toHaveBeenCalledTimes(1); - rerender({ ...props, isThreadRunning: true }); - rerender(props); - await waitFor(() => expect(service.getById).toHaveBeenCalledTimes(2)); - expect(resume).toHaveBeenCalledTimes(status === "streaming" ? 1 : 0); - unmount(); - client.clear(); - } +vi.mock( + "@/app/newchat/adapter/remote-chat-model-adapter", + async (original) => ({ + ...(await original()), + remoteChatModelAdapter: { run: vi.fn() }, + }) ); +vi.mock("@/lib/logger", () => ({ default: { error: vi.fn(), log: vi.fn() } })); + +function conversation() { + return { + conversation_id: "13", + message: [ + { role: "user", message_id: 1, message: "question" }, + { role: "assistant", message_id: 2, message: [], status: "streaming" }, + ], + streaming_message: { message_id: 2, status: "streaming" }, + }; +} it("UT-FE-WB-003 a late history lookup cannot resume the wrong thread", async () => { let finishA!: (value: unknown) => void; @@ -63,12 +39,13 @@ it("UT-FE-WB-003 a late history lookup cannot resume the wrong thread", async () ? new Promise((resolve) => { finishA = resolve; }) - : Promise.resolve({ streaming_message: { status: "streaming" } }) + : Promise.resolve(conversation()) ); const resume = vi.fn(); const runtime = { thread: { - getState: () => ({ messages: [{ id: "last-b" }] }), + getState: () => ({ isRunning: false }), + import: vi.fn(), resumeRun: resume, }, } as unknown as AssistantRuntime; @@ -89,12 +66,10 @@ it("UT-FE-WB-003 a late history lookup cannot resume the wrong thread", async () rerender({ ...props, activeConversationId: "13", activeThreadId: "b" }); await waitFor(() => expect(resume).toHaveBeenCalledOnce()); expect(resume.mock.calls[0][0]).toMatchObject({ - parentId: "last-b", + parentId: "1", runConfig: { custom: { threadId: "13", resume: true } }, }); - await act(async () => - finishA({ streaming_message: { status: "streaming" } }) - ); + await act(async () => finishA(conversation())); expect(resume).toHaveBeenCalledOnce(); expect(props.resumedConversationIdsRef.current.has(12)).toBe(false); }); @@ -127,12 +102,14 @@ it.each(["running", "stopped", "restoring"])( ); it("UT-FE-WB-003 returning after a second detach can resume the same conversation again", async () => { - service.getById - .mockReset() - .mockResolvedValue({ streaming_message: { status: "streaming" } }); + service.getById.mockReset().mockResolvedValue(conversation()); const resume = vi.fn(); const runtime = { - thread: { getState: () => ({ messages: [] }), resumeRun: resume }, + thread: { + getState: () => ({ isRunning: false }), + import: vi.fn(), + resumeRun: resume, + }, } as unknown as AssistantRuntime; const props = { runtime, @@ -157,3 +134,133 @@ it("UT-FE-WB-003 returning after a second detach can resume the same conversatio resume.mock.calls.map(([request]) => request.runConfig.custom.threadId) ).toEqual(["12", "13", "12"]); }); + +it.each(["missing-message", "missing-user", "completed", "lookup-error"])( + "does not create an orphan for %s", + async (mode) => { + const snapshot = conversation(); + if (mode === "missing-message") snapshot.streaming_message.message_id = 99; + if (mode === "missing-user") snapshot.message.shift(); + if (mode === "completed") snapshot.streaming_message.status = "completed"; + service.getById.mockReset(); + if (mode === "lookup-error") + service.getById.mockRejectedValue(new Error("offline")); + else service.getById.mockResolvedValue(snapshot); + const resumeRun = vi.fn(); + const importHistory = vi.fn(); + renderHook(() => + useConversationResume({ + runtime: { + thread: { + getState: () => ({ isRunning: false }), + import: importHistory, + resumeRun, + }, + } as unknown as AssistantRuntime, + activeConversationId: "13", + activeThreadId: "b", + ready: true, + isThreadRunning: false, + selectedAgent: null, + chatMode: "execution", + resumedConversationIdsRef: { current: new Set() }, + handleGenerationStopped: vi.fn(), + }) + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(service.getById).toHaveBeenCalledOnce(); + expect(importHistory).not.toHaveBeenCalled(); + expect(resumeRun).not.toHaveBeenCalled(); + } +); + +it.each(["unmounted", "new-run"])( + "ignores a resolved lookup after %s", + async (mode) => { + let resolve!: (value: ReturnType) => void; + service.getById.mockReset().mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + const importHistory = vi.fn(); + const resumeRun = vi.fn(); + let running = false; + const { unmount } = renderHook(() => + useConversationResume({ + runtime: { + thread: { + getState: () => ({ isRunning: running }), + import: importHistory, + resumeRun, + }, + } as unknown as AssistantRuntime, + activeConversationId: "13", + activeThreadId: "b", + ready: true, + isThreadRunning: false, + selectedAgent: null, + chatMode: "execution", + resumedConversationIdsRef: { current: new Set() }, + handleGenerationStopped: vi.fn(), + }) + ); + if (mode === "unmounted") unmount(); + else running = true; + await act(async () => { + resolve(conversation()); + }); + expect(importHistory).not.toHaveBeenCalled(); + expect(resumeRun).not.toHaveBeenCalled(); + } +); + +it.each(["completed", "streaming"])( + "uses shared %s restore state, then reads fresh state after a live run without replaying", + async (status) => { + service.getById + .mockReset() + .mockResolvedValueOnce({ + ...conversation(), + streaming_message: { ...conversation().streaming_message, status }, + }) + .mockResolvedValue({ ...conversation(), streaming_message: undefined }); + const client = new QueryClient(); + const details = createConversationDetailLoader(client, "resume-test"); + await details.load("12"); + const resume = vi.fn(); + const props = { + runtime: { + thread: { + getState: () => ({ isRunning: false }), + import: vi.fn(), + resumeRun: resume, + }, + } as unknown as AssistantRuntime, + activeConversationId: "12", + activeThreadId: "a", + ready: true, + isThreadRunning: false, + selectedAgent: null, + chatMode: "execution" as const, + resumedConversationIdsRef: { current: new Set() }, + handleGenerationStopped: vi.fn(), + loadDetail: details.load, + invalidateDetail: details.invalidate, + }; + const { rerender, unmount } = renderHook(useConversationResume, { + initialProps: props, + }); + await act(async () => {}); + expect(service.getById).toHaveBeenCalledTimes(1); + rerender({ ...props, isThreadRunning: true }); + rerender(props); + await waitFor(() => expect(service.getById).toHaveBeenCalledTimes(2)); + expect(resume).toHaveBeenCalledTimes(status === "streaming" ? 1 : 0); + unmount(); + client.clear(); + } +); diff --git a/frontend/tests/workbench/ConversationResumeBranch.test.tsx b/frontend/tests/workbench/ConversationResumeBranch.test.tsx new file mode 100644 index 0000000000..592e6dfc81 --- /dev/null +++ b/frontend/tests/workbench/ConversationResumeBranch.test.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { + AssistantRuntimeImpl, + LocalRuntimeCore, +} from "@assistant-ui/core/internal"; +import { RemoteConversationHistoryAdapter } from "@/app/newchat/adapter/conversation-thread-list-adapter"; +import { remoteChatModelAdapter } from "@/app/newchat/adapter/remote-chat-model-adapter"; +import { useConversationResume } from "@/features/workbench/hooks/useConversationResume"; +import type { ApiConversationDetail } from "@/types/conversation"; + +const service = vi.hoisted(() => ({ getById: vi.fn() })); +vi.mock("@/services/conversationService", () => ({ + CONVERSATION_PAGE_SIZE: 20, + conversationService: service, +})); +vi.mock( + "@/app/newchat/adapter/remote-chat-model-adapter", + async (original) => ({ + ...(await original()), + remoteChatModelAdapter: { run: vi.fn() }, + }) +); + +afterEach(() => vi.mocked(remoteChatModelAdapter.run).mockReset()); + +function detail(partial: boolean, completed = false): ApiConversationDetail { + return { + conversation_id: "42", + create_time: 0, + message: [ + { role: "user", message_id: 1, message_index: 0, message: "question" }, + { role: "user", message_id: 3, message_index: 0, message: "question" }, + { role: "user", message_id: 5, message_index: 0, message: "question" }, + ...[2, 4, 6].map((id) => ({ + role: "assistant" as const, + message_id: id, + message_index: 1, + status: + id === 6 && !completed + ? ("streaming" as const) + : ("completed" as const), + message: + id === 6 && !partial && !completed + ? [] + : [{ type: "final_answer", content: `answer-${id}` }], + })), + ], + ...(!completed + ? { + streaming_message: { + message_id: 6, + message_index: 1, + status: "streaming" as const, + message_content: "", + units: [], + }, + } + : {}), + }; +} + +it.each([ + [false, false], + [true, false], + [false, true], + [true, true], +])( + "restores the third retry under its query with partial=%s and preceding=%s", + async (partial, preceding) => { + const snapshot = detail(partial); + if (preceding) { + for (const message of snapshot.message) message.message_index! += 2; + snapshot.streaming_message!.message_index += 2; + snapshot.message.unshift( + { + role: "user", + message_id: 901, + message_index: 0, + message: "earlier question", + }, + { + role: "assistant", + message_id: 902, + message_index: 1, + message: [{ type: "final_answer", content: "earlier answer" }], + } + ); + } + service.getById.mockResolvedValue(snapshot); + let finish!: () => void; + const completion = new Promise((resolve) => { + finish = resolve; + }); + vi.mocked(remoteChatModelAdapter.run).mockImplementation( + async function* () { + yield { content: [{ type: "text", text: "third answer streaming" }] }; + await completion; + yield { content: [{ type: "text", text: "third answer complete" }] }; + } + ); + const runtime = new AssistantRuntimeImpl( + new LocalRuntimeCore( + { adapters: { chatModel: remoteChatModelAdapter } }, + undefined + ) + ); + const history = new RemoteConversationHistoryAdapter( + () => "42", + async () => ({ remoteId: "42" }), + async () => snapshot + ); + runtime.thread.import(await history.load()); + expect(runtime.thread.getState().isRunning).toBe(false); + const { unmount } = renderHook(() => + useConversationResume({ + runtime, + activeConversationId: "42", + activeThreadId: "local-42", + ready: true, + isThreadRunning: false, + selectedAgent: null, + chatMode: "execution", + resumedConversationIdsRef: { current: new Set() }, + handleGenerationStopped: vi.fn(), + }) + ); + try { + await waitFor(() => + expect(remoteChatModelAdapter.run).toHaveBeenCalledTimes(1) + ); + expect(runtime.thread.getState().isRunning).toBe(true); + const visible = runtime.thread.getState().messages; + expect(visible.map((message) => message.role)).toEqual( + preceding + ? ["user", "assistant", "user", "assistant"] + : ["user", "assistant"] + ); + if (preceding) { + expect(visible[0].id).toBe("901"); + expect(visible[1].content).toEqual([ + expect.objectContaining({ type: "text", text: "earlier answer" }), + ]); + } + const current = runtime.thread.getMessageById(visible.at(-1)!.id); + expect(current.getState()).toMatchObject({ + branchNumber: 3, + branchCount: 3, + }); + expect( + runtime.thread + .export() + .messages.filter((entry) => entry.message.role === "assistant") + .map((entry) => entry.parentId) + ).toEqual(preceding ? ["901", "1", "1", "1"] : ["1", "1", "1"]); + current.switchToBranch({ position: "previous" }); + expect(runtime.thread.getState().messages.at(-1)?.id).toBe("4"); + } finally { + await act(async () => { + finish(); + await completion; + }); + unmount(); + } + } +); diff --git a/frontend/tests/workbench/ConversationRetryIdentity.test.ts b/frontend/tests/workbench/ConversationRetryIdentity.test.ts new file mode 100644 index 0000000000..baa0f82be1 --- /dev/null +++ b/frontend/tests/workbench/ConversationRetryIdentity.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { expect, it } from "vitest"; +import { + acknowledgeConversationUser, + getConversationRetryTarget, +} from "@/lib/conversationRetry"; + +it("uses an acknowledged live user without inferring identity from equal text or position", () => { + const users = [ + { id: "local-first", role: "user" }, + { id: "local-second", role: "user" }, + ]; + expect(getConversationRetryTarget(users, "local-first")).toBeNull(); + acknowledgeConversationUser("local-first", 501, 0); + acknowledgeConversationUser("local-second", 503, 2); + expect(getConversationRetryTarget(users, "local-first")).toEqual({ + retry_user_message_id: 501, + retry_message_index: 0, + }); + expect(getConversationRetryTarget(users, "local-second")).toEqual({ + retry_user_message_id: 503, + retry_message_index: 2, + }); +}); + +it("uses the persisted ID and logical index after reload", () => { + expect( + getConversationRetryTarget( + [ + { + id: "99", + role: "user", + metadata: { custom: { historicalMessageIndex: 12 } }, + }, + ], + "99" + ) + ).toEqual({ retry_user_message_id: 99, retry_message_index: 12 }); +}); + +it("rejects missing parents, assistant parents and invalid acknowledgements", () => { + acknowledgeConversationUser("invalid", 0, -1); + expect( + getConversationRetryTarget([{ id: "invalid", role: "user" }], "invalid") + ).toBeNull(); + expect( + getConversationRetryTarget([{ id: "99", role: "assistant" }], "99") + ).toBeNull(); + expect(getConversationRetryTarget([], "missing")).toBeNull(); +}); diff --git a/frontend/tests/workbench/WorkbenchApi.test.ts b/frontend/tests/workbench/WorkbenchApi.test.ts index 1c5b2866a1..b5c0e5b92d 100644 --- a/frontend/tests/workbench/WorkbenchApi.test.ts +++ b/frontend/tests/workbench/WorkbenchApi.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import { expect, it, vi } from "vitest"; import { fetchWorkbenchBootstrap, @@ -14,6 +16,67 @@ vi.mock("@/lib/auth", () => ({ globalThis.fetch(...args), })); +it("ordinary retry forwards the saved target and acknowledges user index zero", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response("", { + status: 200, + headers: { "X-User-Message-Id": "501", "X-User-Message-Index": "0" }, + }) + ); + vi.stubGlobal("fetch", fetch); + const acknowledged = vi.fn(); + await conversationService.runAgent( + { + query: "Same", + history: [], + conversation_id: 42, + retry_user_message_id: 501, + retry_message_index: 0, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + acknowledged + ); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ + retry_user_message_id: 501, + retry_message_index: 0, + }); + expect(acknowledged).toHaveBeenCalledWith(501, 0); + vi.unstubAllGlobals(); +}); + +it.each([ + [null, null], + ["0", "0"], + ["501", "-1"], + ["bad", "2"], +])("invalid user boundary is never acknowledged: %s %s", async (id, index) => { + const headers: Record = {}; + if (id !== null) headers["X-User-Message-Id"] = id; + if (index !== null) headers["X-User-Message-Index"] = index; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("", { status: 200, headers })) + ); + const acknowledged = vi.fn(); + await conversationService.runAgent( + { query: "Same", history: [] }, + undefined, + undefined, + undefined, + undefined, + undefined, + acknowledged + ); + expect(acknowledged).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); +}); + it("Workbench bootstrap and published preview carry the authenticated identity", async () => { const fetch = vi .fn() diff --git a/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/case.yaml b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/case.yaml new file mode 100644 index 0000000000..91da227717 --- /dev/null +++ b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/case.yaml @@ -0,0 +1,52 @@ +schema_version: '1.0' +stage: D4 +module: AGENT-CORE +case: + case_id: CHAT-RETRY-HISTORY-D4-001 + feature_id: F-179 + business_rule_ids: [F-179-RULE-02] + title: Real ordinary Agent retry siblings and identical independent questions survive reload + type: E2E + priority: P0 + status: active + automation: automated + objective: Prove both Core retry persistence and branch navigation through the deployed browser and database. + preconditions: + - Authorized file-assistant source and configured real model; deployed Web runtime and database with machine-local fixture identity. + test_data: + profile: real_smoke + locale: zh + steps: + - order: 1 + action: Clone the authorized file assistant and publish each Core in turn; send one question and regenerate twice using the message Refresh action. + expected: Three real answers share one persisted user and adjacent assistant index, with a single selected answer and 3/3 shown. + - order: 2 + action: Reload saved history and the page, navigate 1/3 through 3/3, then submit the exact same question as a new message and reload again. + expected: Earlier three answers remain selectable; the new identical question has its own boundary and answer and is never collapsed into a fourth retry. + - order: 3 + action: Regenerate a longer third answer in a separate owned conversation, reload while it is running, and return to saved history after completion. + expected: Recovery keeps the same persisted third assistant and exactly three siblings, with no orphan or fourth run; only owned fixtures are cleaned. + expected_results: + - Both smolagents and Jiuwen preserve selectable persisted retries and independent repeated questions. + - Streaming recovery retains exact ownership and count without generating another answer. + forbidden_side_effects: + - No source assistant or source history mutation, fabricated model/tool result, text-based historical merging or credential output. + - History load starts no new execution; a resume reconnect is allowed only for an already streaming answer. + actor: Authorized Agent user + journey: + - Generate, retry, reload and switch results on both Cores. + final_business_result: + - Persisted x/y navigation agrees with live display and normal repeated questions remain independent. + failure_evidence: + - Browser assertions, persisted IDs and indexes, sanitized request summaries and failure screenshots. + acceptance: + - id: AC-01 + description: Completed retry siblings and independent repeated questions survive reload on both Cores. + steps: [1, 2] + expected_results: [1] + forbidden_side_effects: [1] + - id: AC-02 + description: Running retry recovery preserves three siblings and isolated cleanup. + steps: [3] + expected_results: [2] + forbidden_side_effects: [2] diff --git a/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/execution.yaml b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/execution.yaml new file mode 100644 index 0000000000..7b5459ebff --- /dev/null +++ b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: CHAT-RETRY-HISTORY-D4-001 +implementations: +- framework: playwright + file: test.spec.ts + selector: CHAT-RETRY-HISTORY-D4-001 + profiles: [real_smoke] +timeout_seconds: 1800 +acceptance_bindings: + AC-01: [CHAT-RETRY-HISTORY-D4-001] + AC-02: [CHAT-RETRY-HISTORY-D4-001] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/fixture.py b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/fixture.py new file mode 100644 index 0000000000..d61002a9a2 --- /dev/null +++ b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/fixture.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Reuse the owned file-assistant fixture and change only its root Core.""" +import importlib.util +import json +import sys +from pathlib import Path + +import httpx + + +mode, filename, helper_path = sys.argv[1:4] +if mode == "select-core": + state = json.loads(Path(filename).read_text()) + core = sys.argv[4] + assert core in {"smolagents", "jiuwen"} + assert state["agent_id"] in state["owned_agents"] and state["agent_id"] != state["source"] + with httpx.Client(base_url="http://nexent-config:5010", trust_env=False, timeout=90, + headers={"Authorization": "Bearer " + state["access_token"]}) as client: + response = client.post("/agent/update", json={"agent_id": state["agent_id"], + **state["copied_fields"], "agent_core": core}) + assert response.status_code == 200 + response = client.post(f"/agent/{state['agent_id']}/publish", json={"version_name": "Retry history " + core}) + assert response.status_code == 200 + print(json.dumps({"configured_core": core})) +else: + spec = importlib.util.spec_from_file_location("owned_file_fixture", helper_path) + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + sys.argv = [sys.argv[0], mode, filename, *sys.argv[4:]] + helper.main() diff --git a/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/test.spec.ts b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/test.spec.ts new file mode 100644 index 0000000000..92bb9b0845 --- /dev/null +++ b/test-e2e/cases/CHAT-RETRY-HISTORY-D4-001/test.spec.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { journey } from "../../infra/automation/d4/runner/journey"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +journey("CHAT-RETRY-HISTORY-D4-001", async ({ page, contract, expect }) => { + const input = JSON.parse( + readFileSync(process.env.NEXENT_REAL_UI_CONFIG!, "utf8"), + ); + const base = process.env.NEXENT_BASE_URL || "http://127.0.0.1:3000"; + const container = input.container || "nexent-runtime"; + const target = + "/tmp/nexent_retry_" + randomUUID().replaceAll("-", "").slice(0, 12); + const stateFile = join(contract.caseDir, "private-state.json"); + const outcomes: any[] = []; + const save = () => + writeFileSync( + join(contract.caseDir, "retry-outcomes.json"), + JSON.stringify(outcomes, null, 2), + ); + const docker = (...args: string[]) => + execFileSync("docker", args, { + encoding: "utf8", + timeout: 120000, + stdio: ["ignore", "pipe", "pipe"], + }); + const fixture = (mode: string, ...args: unknown[]) => + JSON.parse( + docker( + "exec", + "-e", + "PYTHONPATH=/opt/backend", + "-w", + "/opt/backend", + container, + "/opt/backend/.venv/bin/python", + target + ".py", + mode, + target + ".json", + target + "_helper.py", + ...args.map(String), + ) + .trim() + .split(/\r?\n/) + .at(-1)!, + ); + for (const [source, suffix] of [ + ["CHAT-RETRY-HISTORY-D4-001/fixture.py", ".py"], + ["JIUWEN-REAL-UI-D4-001/fixture.py", "_helper.py"], + ]) { + docker( + "cp", + join(process.env.NEXENT_REPO!, "test-e2e/cases", source), + container + ":" + target + suffix, + ); + } + let state: any; + let runs = 0; + page.on("request", (request) => { + if ( + request.method() === "POST" && + new URL(request.url()).pathname === "/api/agent/run" + ) + runs++; + }); + contract.deferCleanup(async () => { + try { + const cleaned = fixture("cleanup"); + outcomes.push(cleaned); + save(); + expect(cleaned.cleanup).toBe(true); + expect(cleaned.source_unchanged).toBe(true); + } finally { + if (existsSync(stateFile)) unlinkSync(stateFile); + docker( + "exec", + container, + "/opt/backend/.venv/bin/python", + "-c", + `from pathlib import Path;[Path('${target}'+s).unlink(missing_ok=True) for s in ['.py','_helper.py']]`, + ); + } + }); + await contract.precondition( + contract.expectedIds("preconditions")[0], + async () => { + expect( + fixture( + "setup", + input.source_agent_id, + input.user_id, + input.source_message_id, + ).setup, + ).toBe(true); + docker("cp", container + ":" + target + ".json", stateFile); + state = JSON.parse(readFileSync(stateFile, "utf8")); + await page.context().addCookies([ + { + name: "nexent_access_token", + value: state.access_token, + url: base, + httpOnly: true, + sameSite: "Lax", + expires: state.expires_at, + }, + { + name: "nexent_token_expires_at", + value: String(state.expires_at), + url: base, + sameSite: "Lax", + expires: state.expires_at, + }, + ]); + }, + ); + const users = () => page.locator('[data-slot="aui_user-message-root"]'); + const answers = () => + page.locator('[data-slot="aui_assistant-message-root"]'); + const counter = (index = 0) => + answers().nth(index).locator(".aui-branch-picker-root"); + const records = (cid: number) => fixture("check", cid).messages; + const open = async (cid?: number) => { + const count = runs; + await page.goto( + `${base}/zh/newchat?${cid ? "thread_id=" + cid : "agent_id=" + state.agent_id}`, + ); + await expect(page.getByRole("textbox").last()).toBeVisible(); + if (cid) await expect(answers().first()).toBeVisible(); + expect(runs).toBe(count); + }; + const request = async (action: () => Promise, streaming = false) => { + const wire = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/agent/run", + { timeout: 30000 }, + ); + await action(); + const response = await wire; + expect(response.status()).toBe(200); + const body = response.request().postDataJSON(); + const cid = + body.conversation_id || Number(response.headers()["conversation_id"]); + expect(cid).toBeGreaterThan(0); + if (!streaming) { + const events = (await response.body()) + .toString("utf8") + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => JSON.parse(line.slice(5).trim())); + expect(events.filter((event) => event.type === "error")).toEqual([]); + await expect( + answers() + .last() + .locator('[data-slot="aui_assistant-message-completion-indicator"]'), + ).toBeVisible(); + await expect.poll(() => records(cid).at(-1)?.status).toBe("completed"); + } + return { cid, body }; + }; + const send = async (text: string) => { + await page.getByRole("textbox").last().fill(text); + return request(() => + page.getByRole("button", { name: "发送", exact: true }).click(), + ); + }; + const retry = (streaming = false) => + request( + () => + answers() + .last() + .getByRole("button", { name: "刷新", exact: true }) + .click(), + streaming, + ); + const completed: { + core: string; + cid: number; + marker: string; + question: string; + original: any[]; + }[] = []; + const steps = contract.expectedIds("steps"); + await contract.step(steps[0], async () => { + for (const core of ["smolagents", "jiuwen"]) { + expect(fixture("select-core", core).configured_core).toBe(core); + await open(); + const marker = "RETRY_" + core + "_" + randomUUID().slice(0, 8); + const question = `不要调用工具,只回复“重试验收 ${marker}”,不要补充其他内容。`; + const first = await send(question); + for (let count = 2; count <= 3; count++) { + const result = await retry(); + expect(result.cid).toBe(first.cid); + expect(result.body.retry_user_message_id).toBeGreaterThan(0); + await expect(counter()).toContainText(`${count} / ${count}`); + } + const rows = records(first.cid); + expect(rows.filter((row: any) => row.role === "user")).toHaveLength(1); + expect( + rows + .filter((row: any) => row.role === "assistant") + .map((row: any) => row.index), + ).toEqual([1, 1, 1]); + completed.push({ + core, + cid: first.cid, + marker, + question, + original: rows, + }); + outcomes.push({ + core, + completed_retry: true, + saved_user_rows: 1, + saved_answers: 3, + }); + save(); + } + }); + await contract.step(steps[1], async () => { + for (const item of completed) { + fixture("select-core", item.core); + await open(item.cid); + const before = runs; + await page.reload(); + await expect(counter()).toContainText("3 / 3"); + await expect(users()).toHaveCount(1); + await expect(answers()).toHaveCount(1); + const buttons = counter().getByRole("button"); + await buttons.first().click(); + await expect(counter()).toContainText("2 / 3"); + await buttons.first().click(); + await expect(counter()).toContainText("1 / 3"); + await buttons.last().click(); + await buttons.last().click(); + await expect(counter()).toContainText("3 / 3"); + expect(runs).toBe(before); + await send(item.question); + const rows = records(item.cid); + expect( + rows + .filter((row: any) => row.role === "user") + .map((row: any) => row.index), + ).toEqual([0, 2]); + expect(rows.filter((row: any) => row.role === "assistant")).toHaveLength( + 4, + ); + expect( + rows.filter((row: any) => + item.original.some((old) => old.id === row.id), + ), + ).toEqual(item.original); + await open(item.cid); + await page.reload(); + await expect(users()).toHaveCount(2); + await expect(answers()).toHaveCount(2); + await expect(counter()).toContainText("3 / 3"); + outcomes.push({ + core: item.core, + completed_reload: true, + branch_navigation: true, + independent_equal_question: true, + }); + save(); + } + }); + await contract.step(steps[2], async () => { + for (const core of ["smolagents", "jiuwen"]) { + fixture("select-core", core); + await open(); + const marker = "STREAM_" + core + "_" + randomUUID().slice(0, 8); + const first = await send( + `不要调用任何工具。请按1到120逐行写中文简短说明,每行至少10字,最后单独写${marker}。`, + ); + await retry(); + await retry(true); + await expect + .poll( + () => + records(first.cid).filter((row: any) => row.role === "assistant") + .length, + ) + .toBe(3); + const before = records(first.cid); + expect( + before.filter((row: any) => row.role === "assistant").at(-1).status, + ).toBe("streaming"); + // Enter canonical history while the same backend answer is still running. + const reconnect = page.waitForResponse( + (response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "POST" && + url.pathname === "/api/agent/run" && + url.searchParams.get("resume") === "true" + ); + }, + { timeout: 30000 }, + ); + await page.goto(`${base}/zh/newchat?thread_id=${first.cid}`); + expect((await reconnect).status()).toBe(200); + await expect(counter()).toContainText("3 / 3"); + await expect(users()).toHaveCount(1); + await expect(answers()).toHaveCount(1); + await expect + .poll( + () => + records(first.cid).find((row: any) => row.id === before.at(-1).id) + ?.status, + { timeout: 180000 }, + ) + .toBe("completed"); + await expect( + answers() + .last() + .locator('[data-slot="aui_assistant-message-completion-indicator"]'), + ).toBeVisible({ timeout: 180000 }); + await open(first.cid); + await page.reload(); + await expect(counter()).toContainText("3 / 3"); + const after = records(first.cid); + expect(after.map((row: any) => row.id)).toEqual( + before.map((row: any) => row.id), + ); + expect(after.filter((row: any) => row.role === "assistant")).toHaveLength( + 3, + ); + outcomes.push({ + core, + streaming_resume: true, + live_reconnect_completed: true, + same_persisted_answer: true, + no_fourth_branch: true, + }); + save(); + } + }); + const assertions = contract.expectedIds("assertions"); + await contract.assertion(assertions[0], async () => { + expect( + outcomes.filter( + (row) => row.independent_equal_question && row.branch_navigation, + ), + ).toHaveLength(2); + }); + await contract.assertion(assertions[1], async () => { + expect( + outcomes.filter((row) => row.streaming_resume && row.no_fourth_branch), + ).toHaveLength(2); + }); +}); diff --git a/test-e2e/cases/UT-FE-040/case.yaml b/test-e2e/cases/UT-FE-040/case.yaml index 1d21b733fc..7fdffc53e0 100644 --- a/test-e2e/cases/UT-FE-040/case.yaml +++ b/test-e2e/cases/UT-FE-040/case.yaml @@ -1,11 +1,12 @@ -schema_version: '1.0' +schema_version: "1.0" stage: D1 module: V5-BASELINE case: case_id: UT-FE-040 feature_id: F-179 business_rule_ids: - - F-179-RULE-01 + - F-179-RULE-01 + - F-179-RULE-02 title: 普通对话的前端消息按用户轮次正确分组 type: FE-COMP priority: P0 @@ -13,40 +14,78 @@ case: automation: automated objective: 普通对话的前端消息按用户轮次正确分组 preconditions: - - 本地测试环境健康检查通过,NEXENT_TEST_HOME 位于源码仓库外。 - - 测试基线固定为 develop 当前批次锁定的产品提交,当次资源使用 run_id 隔离。 - - 所需资产存在:${NEXENT_TEST_HOME}/config/users.yaml;${NEXENT_TEST_HOME}/config/secrets.env;${NEXENT_TEST_HOME}/config/models.yaml。 + - 本地测试环境健康检查通过,NEXENT_TEST_HOME 位于源码仓库外。 + - 测试基线固定为 develop 当前批次锁定的产品提交,当次资源使用 run_id 隔离。 + - 所需资产存在:${NEXENT_TEST_HOME}/config/users.yaml;${NEXENT_TEST_HOME}/config/secrets.env;${NEXENT_TEST_HOME}/config/models.yaml。 test_data: - legacy_asset_declaration: '- ${NEXENT_TEST_HOME}/config/users.yaml + legacy_asset_declaration: "- ${NEXENT_TEST_HOME}/config/users.yaml - ${NEXENT_TEST_HOME}/config/secrets.env - - ${NEXENT_TEST_HOME}/config/models.yaml' + - ${NEXENT_TEST_HOME}/config/models.yaml" steps: - - order: 1 - action: 构造两个连续用户轮次及各自的 Agent 工具输出。 - expected: 每条工具输出只归属发起它的用户轮次。 - - order: 2 - action: 调用 newchat RemoteConversationHistoryAdapter.load,检查消息仓库 parentId 链及各 assistant 消息的 tool-call 输出。 - expected: 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 - - order: 3 - action: 分别核对两个轮次的分组和输出顺序。 - expected: 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 + - order: 1 + action: 构造两个连续用户轮次及各自的 Agent 工具输出。 + expected: 每条工具输出只归属发起它的用户轮次。 + - order: 2 + action: + 调用 newchat RemoteConversationHistoryAdapter.load,检查消息仓库 parentId 链及各 assistant + 消息的 tool-call 输出。 + expected: 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 + - order: 3 + action: 分别核对两个轮次的分组和输出顺序。 + expected: 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 + - order: 4 + action: + Load an empty or partially persisted third retry through the production + history adapter; reconnect with the shared hook and real assistant-ui runtime; + detach and enter again, then load completed history. + expected: + One user query owns exactly three sibling answers with branch 3/3 selected; + older answers remain navigable and completed history does not reconnect. expected_results: - - 每条工具输出只归属发起它的用户轮次。 - - 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 - forbidden_side_effects: [] + - 每条工具输出只归属发起它的用户轮次。 + - 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 + - Refreshing or re-entering during the third retry preserves 3/3 under the original + query, including empty and partial streaming messages; completed history also + restores 3/3. + forbidden_side_effects: + - Restoration creates an assistant-to-assistant link, a fourth retry branch, a duplicate + stream or a response without its originating query. source: legacy_workbook_sha256: aa4b32414d30f12b3b64e14236268145e556bd7cfc5d3eb8df80731608ebc022 legacy_sheet: 02_测试用例基线 legacy_row: 379 unit_boundary: newchat 历史轮次消息仓库 inputs: - - '- ${NEXENT_TEST_HOME}/config/users.yaml + - "- ${NEXENT_TEST_HOME}/config/users.yaml - - ${NEXENT_TEST_HOME}/config/secrets.env + - ${NEXENT_TEST_HOME}/config/secrets.env - - ${NEXENT_TEST_HOME}/config/models.yaml' + - ${NEXENT_TEST_HOME}/config/models.yaml" assertions: - - 每条工具输出只归属发起它的用户轮次。 - - 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 + - 每条工具输出只归属发起它的用户轮次。 + - 轮次顺序与输出顺序保持一致,不引入已删除的 HITL 队列状态。 + acceptance: + - id: AC-01 + description: + Completed ordinary conversation turns retain tool output ownership + and order. + steps: + - 1 + - 2 + - 3 + expected_results: + - 1 + - 2 + - id: AC-02 + description: + Refresh and repeated re-entry keep empty or partial third retries + under their user as 3/3, preserving older answers; completed history does not + reconnect. + steps: + - 4 + expected_results: + - 3 + forbidden_side_effects: + - 1 diff --git a/test-e2e/cases/UT-FE-040/execution.yaml b/test-e2e/cases/UT-FE-040/execution.yaml index 3207d90376..bfac713448 100644 --- a/test-e2e/cases/UT-FE-040/execution.yaml +++ b/test-e2e/cases/UT-FE-040/execution.yaml @@ -1,7 +1,15 @@ -schema_version: '1.0' +schema_version: "1.0" case_id: UT-FE-040 implementations: -- framework: vitest - file: test.test.tsx - selector: UT-FE-040 + - framework: vitest + file: test.test.tsx + selector: UT-FE-040 notes: V5 source binding migrated; runtime parity is not yet certified. +acceptance_bindings: + AC-01: + - UT-FE-040 newchat history turn ownership > UT-FE-040 groups normal chat output by the originating user turn + AC-02: + - UT-FE-040 preserves 3/3 through streaming replay with partial=false and refresh + - UT-FE-040 preserves 3/3 through streaming replay with partial=true and refresh + - UT-FE-040 preserves 3/3 through streaming replay with partial=false and re-entry + - UT-FE-040 preserves 3/3 through streaming replay with partial=true and re-entry diff --git a/test-e2e/cases/UT-FE-040/test.test.tsx b/test-e2e/cases/UT-FE-040/test.test.tsx index 7381c2591a..f014d06a5f 100644 --- a/test-e2e/cases/UT-FE-040/test.test.tsx +++ b/test-e2e/cases/UT-FE-040/test.test.tsx @@ -1,10 +1,31 @@ -import { describe, expect, it } from "vitest"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { ReadableStream } from "node:stream/web"; import { - collectStream, - restoreHistory, - summaryEvent, - summaryParts, -} from "../../infra/automation/d1/frontend/newchat-fixture"; + AssistantRuntimeImpl, + LocalRuntimeCore, +} from "@assistant-ui/core/internal"; +import { conversationService } from "@/services/conversationService"; +import { remoteChatModelAdapter } from "@/app/newchat/adapter/remote-chat-model-adapter"; +import { RemoteConversationHistoryAdapter } from "@/app/newchat/adapter/conversation-thread-list-adapter"; +import { useConversationResume } from "@/hooks/chat/useConversationResume"; +import type { ApiConversationDetail } from "@/types/conversation"; +import type { Agent } from "@/types/agentConfig"; + +vi.mock("@/services/conversationService", () => ({ + CONVERSATION_PAGE_SIZE: 20, + conversationService: { getById: vi.fn(), runAgent: vi.fn(), stop: vi.fn() }, +})); + +async function restoreHistory(messages: unknown[]) { + return new RemoteConversationHistoryAdapter( + () => "901", + async () => ({ remoteId: "901" }), + async () => ({ conversation_id: "901", message: messages }) as never, + ).load(); +} describe("UT-FE-040 newchat history turn ownership", () => { it("UT-FE-040 groups normal chat output by the originating user turn", async () => { @@ -81,3 +102,212 @@ describe("UT-FE-040 newchat history turn ownership", () => { expect(history.headId).toBe("4"); }); }); + +function retrySnapshot( + partial: boolean, + completed = false, +): ApiConversationDetail { + return { + conversation_id: "901", + create_time: 0, + message: [ + ...[1, 3, 5].map((id) => ({ + role: "user" as const, + message_id: id, + message_index: 0, + message: "create a document", + })), + ...[2, 4, 6].map((id) => ({ + role: "assistant" as const, + message_id: id, + message_index: 1, + status: + id === 6 && !completed + ? ("streaming" as const) + : ("completed" as const), + message: + id === 6 && !partial && !completed + ? [] + : id === 6 && !completed + ? [{ type: "model_output_thinking", content: "thinking-6" }] + : [{ type: "final_answer", content: `answer-${id}` }], + })), + ], + ...(!completed + ? { + streaming_message: { + message_id: 6, + message_index: 1, + status: "streaming" as const, + message_content: "", + units: [], + }, + } + : {}), + }; +} + +async function loadRetry(snapshot: ApiConversationDetail) { + return new RemoteConversationHistoryAdapter( + () => "901", + async () => ({ remoteId: "901" }), + async () => snapshot, + ).load(); +} + +it.each([ + [false, "refresh"], + [true, "refresh"], + [false, "re-entry"], + [true, "re-entry"], +] as const)( + "UT-FE-040 preserves 3/3 through streaming replay with partial=%s and %s", + async (partial, path) => { + let snapshot = retrySnapshot(partial); + const getById = vi + .mocked(conversationService.getById) + .mockImplementation(async () => snapshot); + const controllers: Array<{ + enqueue(chunk: Uint8Array): void; + close(): void; + }> = []; + vi.mocked(conversationService.stop).mockResolvedValue(undefined as never); + vi.mocked(conversationService.runAgent).mockImplementation( + async (_request, signal) => + new ReadableStream({ + start(controller) { + let open = true; + const transport = { + enqueue: (chunk: Uint8Array) => controller.enqueue(chunk), + close: () => { + if (open) { + open = false; + controller.close(); + } + }, + }; + controllers.push(transport); + signal?.addEventListener("abort", transport.close, { once: true }); + }, + }).getReader() as never, + ); + const core = new LocalRuntimeCore( + { adapters: { chatModel: remoteChatModelAdapter } }, + undefined, + ); + const runtime = new AssistantRuntimeImpl(core); + const restored = await loadRetry(snapshot); + expect(restored).not.toHaveProperty("unstable_resume"); + runtime.thread.import(restored); + expect(runtime.thread.getState().isRunning).toBe(false); + expect(runtime.thread.getMessageById("6").getState()).toMatchObject({ + branchNumber: 3, + branchCount: 3, + }); + const stopped = { current: new Set() }; + const props = { + runtime, + activeConversationId: "901", + activeThreadId: "thread-901", + ready: true, + isThreadRunning: false, + selectedAgent: { id: 1 } as Agent, + chatMode: "execution" as const, + resumedConversationIdsRef: stopped, + handleGenerationStopped: vi.fn(), + }; + let mounted = renderHook(() => useConversationResume(props)); + const assertBranches = () => { + const visible = runtime.thread.getState().messages; + expect(visible.map((message) => message.role)).toEqual([ + "user", + "assistant", + ]); + expect(visible[0].content).toEqual([ + { type: "text", text: "create a document" }, + ]); + const answer = runtime.thread.getMessageById(visible.at(-1)!.id); + expect(answer.getState()).toMatchObject({ + branchNumber: 3, + branchCount: 3, + }); + const rows = runtime.thread.export().messages; + expect( + rows + .filter((entry) => entry.message.role === "assistant") + .map((entry) => entry.parentId), + ).toEqual(["1", "1", "1"]); + expect(rows.map((entry) => entry.message.id)).toEqual( + expect.arrayContaining(["1", "2", "4"]), + ); + }; + try { + await waitFor(() => { + expect(controllers).toHaveLength(1); + }); + assertBranches(); + if (partial) + expect( + JSON.stringify(runtime.thread.getState().messages.at(-1)?.content), + ).toContain("thinking-6"); + if (path === "re-entry") { + for (let attempt = 2; attempt <= 3; attempt++) { + mounted.unmount(); + await act(async () => { + core.threads.getMainThreadRuntimeCore().detach(); + }); + await waitFor(() => + expect(runtime.thread.getState().isRunning).toBe(false), + ); + mounted = renderHook(() => useConversationResume(props)); + await waitFor(() => expect(controllers).toHaveLength(attempt)); + assertBranches(); + } + } + const controller = controllers.at(-1)!; + snapshot = retrySnapshot(true, true); + await act(async () => { + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ type: "final_answer", content: "answer-6" })}\n\n`, + ), + ); + controller.close(); + }); + await waitFor(() => + expect(runtime.thread.getState().isRunning).toBe(false), + ); + assertBranches(); + expect( + JSON.stringify(runtime.thread.getState().messages.at(-1)?.content), + ).toContain("answer-6"); + mounted.unmount(); + runtime.thread.import(await loadRetry(snapshot)); + const lookupCount = getById.mock.calls.length; + const complete = renderHook(() => useConversationResume(props)); + await waitFor(() => + expect(getById).toHaveBeenCalledTimes(lookupCount + 1), + ); + await act(async () => { + await Promise.resolve(); + }); + assertBranches(); + expect(controllers).toHaveLength(path === "refresh" ? 1 : 3); + const current = runtime.thread.getMessageById("6"); + current.switchToBranch({ position: "previous" }); + expect(runtime.thread.getState().messages.at(-1)?.id).toBe("4"); + expect(JSON.stringify(runtime.thread.getState().messages.at(-1)?.content)).toContain("answer-4"); + runtime.thread + .getMessageById("4") + .switchToBranch({ position: "previous" }); + expect(runtime.thread.getState().messages.at(-1)?.id).toBe("2"); + expect(JSON.stringify(runtime.thread.getState().messages.at(-1)?.content)).toContain("answer-2"); + complete.unmount(); + } finally { + mounted.unmount(); + await act(async () => { + core.threads.getMainThreadRuntimeCore().detach(); + }); + } + }, +); diff --git a/test-e2e/changes/bugs/CHAT-RETRY-PERSISTENCE-20261029.yaml b/test-e2e/changes/bugs/CHAT-RETRY-PERSISTENCE-20261029.yaml new file mode 100644 index 0000000000..2404f20e18 --- /dev/null +++ b/test-e2e/changes/bugs/CHAT-RETRY-PERSISTENCE-20261029.yaml @@ -0,0 +1,47 @@ +schema_version: '1.0' +change: + change_id: CHAT-RETRY-PERSISTENCE-20261029 + change_type: bugfix + bug_id: CHAT-RETRY-PERSISTENCE-20261029 + title: Preserve persisted ordinary Agent retry siblings after history reload + description: Carry an explicit saved user target for ordinary regenerate, authorize + reuse of that user without inserting another turn, and restore completed and streaming siblings. + observed_behavior: Both Cores show 2/2 live but reload as two questions and two answers at indexes 0 through 3. + expected_behavior: Retry answers share one authorized user boundary and remain selectable x/y after reload; + independent repeated questions retain distinct turns and streaming reconnect adds no fourth branch. + escaped_stage: production + coverage_gap: + type: missing_assertion + description: Existing history tests seed same-index retries and do not exercise ordinary retry persistence; + current shared and inline reconnect still attach below an assistant. + feature_contract_changed: true + affected_features: + existing: [F-056] + modified: [F-179] + added: [] + retired: [] + affected_cases: + existing: [] + modified: [UT-FE-040] + added: [CHAT-RETRY-HISTORY-D4-001] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - New turns reserve increasing boundaries even when text and attachments are identical. + - Explicit retry targets reuse the owned original user and reject wrong conversation role content index or attachment identity. + - Ordinary retry requests and saved boundary acknowledgements preserve exact server identity without text inference. + - Completed and streaming third answers remain siblings with exact branch count and old answers remain navigable. + - Invalid or stale streaming recovery cannot mutate another conversation or start duplicate runs. + reason: Unit and actual assistant-ui component proof complement a deployed real-model browser/database journey. + test_selectors: + - test/backend/database/test_conversation_append_order.py + - test/backend/database/test_conversation_history_stopped.py + - test/backend/services/test_conversation_runtime_boundary.py + - frontend/tests/workbench/ConversationRetryIdentity.test.ts + - frontend/tests/workbench/WorkbenchApi.test.ts + - frontend/tests/workbench/ConversationResumeBranch.test.tsx + - frontend/tests/workbench/ConversationResume.test.tsx + notes: Existing chat-retry-branch-resume design is the owning SPEC. Retain all prior + D1 cases and normal-turn assertions. No SQL or model-directory change is planned. + Old ambiguous flattened history is not merged by equal text. Global F-111 rule blocker remains separate. diff --git a/test-e2e/changes/bugs/CHAT-RETRY-RESUME-20261020.yaml b/test-e2e/changes/bugs/CHAT-RETRY-RESUME-20261020.yaml new file mode 100644 index 0000000000..e20f60d03c --- /dev/null +++ b/test-e2e/changes/bugs/CHAT-RETRY-RESUME-20261020.yaml @@ -0,0 +1,52 @@ +schema_version: '1.0' +change: + change_id: CHAT-RETRY-RESUME-20261020 + change_type: bugfix + bug_id: CHAT-RETRY-RESUME-20261020 + title: Preserve retry branches when restoring an in-progress conversation + description: Use one shared resume hook for newchat and Workbench, restoring the + persisted streaming answer under its originating user. + observed_behavior: Refreshing during a third answer shows 2/2 followed by an assistant + without a query; reload after completion shows 3/3. + expected_behavior: Empty and partial streaming answers resume as the third sibling + under the original query, preserving the two older answers. + escaped_stage: production + coverage_gap: + type: missing_assertion + description: Resume hook UT mocks the runtime; UT-FE-040 checks completed turn + ownership but not restoration into a real branching runtime. + feature_contract_changed: false + affected_features: + existing: + - F-179 + - F-056 + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: + - UT-FE-040 + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Resume exactly once under the originating user and preserve retry siblings + - Empty and partially persisted third answers survive refresh and repeated re-entry + - Late responses, active runs, stopped conversations and failed lookups cannot + start an orphan answer + test_selectors: + - frontend/tests/workbench/ConversationResume.test.tsx + - frontend/tests/workbench/ConversationResumeBranch.test.tsx + reason: Frontend-only changes; strengthen hook UT and the existing component-contract + case. Python code and API behavior are unchanged. + notes: 'Design is in sibling nexent-doc/docs/Developing/chat-retry-branch-resume/design.md. + Strengthen UT-FE-040 rather than duplicate D1; real history adapter, shared hook + and assistant-ui runtime collaborate across a controlled transport boundary. AGT-016/017 + cover backend transport semantics and are unaffected. No backend, model, deployment + or protocol changes. Live browser journey remains separately reported. Follow-up + 2026-10-23: develop c250eb8f6 does not contain original commit 8d47e6314. Restore + the ownership fix while preserving #4176 injected detail reader and invalidation; + retain cache tests and add preceding-turn variants. Live evidence is recorded + in the existing sibling design.' diff --git a/test-e2e/features/F-179/cases.md b/test-e2e/features/F-179/cases.md index 2cf2612ed5..5d930c06b3 100644 --- a/test-e2e/features/F-179/cases.md +++ b/test-e2e/features/F-179/cases.md @@ -5,3 +5,4 @@ - D1 [UT-BE-027](../../cases/UT-BE-027/case.yaml) · active - D1 [UT-FE-040](../../cases/UT-FE-040/case.yaml) · active - D2 [API-167](../../cases/API-167/case.yaml) · active +- D4 [CHAT-RETRY-HISTORY-D4-001](../../cases/CHAT-RETRY-HISTORY-D4-001/case.yaml) · active diff --git a/test-e2e/features/F-179/feature.yaml b/test-e2e/features/F-179/feature.yaml index 3f7f49b540..7aef03ce7b 100644 --- a/test-e2e/features/F-179/feature.yaml +++ b/test-e2e/features/F-179/feature.yaml @@ -8,10 +8,13 @@ feature: status: active description: '智能体运行与对话 / Human-in-the-loop: 旧 HITL 控制字段被拒绝,普通对话与消息分组继续可用;审批、暂停、引导功能已移除' business_rules: + - rule_id: F-179-RULE-02 + description: Ordinary Agent retries retain the original owned user boundary and selectable answer siblings after history reload; identical independent questions remain separate turns and reconnect does not add an orphan branch. - rule_id: F-179-RULE-01 description: 旧 HITL 控制字段被拒绝,普通对话与消息分组继续可用;审批、暂停、引导功能已移除 required_test_stages: - D1 - D2 + - D4 source: notes: Migrated from V5 01_代码功能清单 without changing the functional description. diff --git a/test-e2e/infra/automation/d1/frontend/vitest.config.ts b/test-e2e/infra/automation/d1/frontend/vitest.config.ts index 1ff75914af..bfcc907f26 100644 --- a/test-e2e/infra/automation/d1/frontend/vitest.config.ts +++ b/test-e2e/infra/automation/d1/frontend/vitest.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ { find: /^@dnd-kit\/utilities$/, replacement: path.join(testNodeModules, "@dnd-kit/utilities") }, { find: /^react-shiki$/, replacement: path.join(testNodeModules, "react-shiki") }, { find: /^@assistant-ui\/react$/, replacement: path.join(testNodeModules, "@assistant-ui/react") }, + { find: /^@assistant-ui\/core\/internal$/, replacement: path.join(testNodeModules, "@assistant-ui/core/dist/internal.js") }, { find: /^@assistant-ui\/react-markdown$/, replacement: path.join(testNodeModules, "@assistant-ui/react-markdown") }, // Preview imports are analyzed even when an AIDP test does not open a // document. Use the isolated, locked dependencies rather than host drift. diff --git a/test/backend/database/test_conversation_append_order.py b/test/backend/database/test_conversation_append_order.py index 1ddfc4746b..fb1fb433a9 100644 --- a/test/backend/database/test_conversation_append_order.py +++ b/test/backend/database/test_conversation_append_order.py @@ -3,6 +3,7 @@ """Database-owned turn indices, independent of frontend message counts.""" from contextlib import contextmanager from unittest.mock import MagicMock +from types import SimpleNamespace import pytest @@ -38,3 +39,34 @@ def test_unowned_conversation_never_inserts(store): with pytest.raises(module.ConversationNotFoundError): module.append_conversation_user_message(7, "Current", [], "other") create.assert_not_called() + + +def test_retry_reuses_original_user_and_verifies_scoped_queries(store): + module, session, lock, create = store + session.scalar.side_effect = [7, SimpleNamespace(message_id=20, message_index=4, + message_content="Same", minio_files='[{"object_name":"owned.png","url":"old"}]'), 21] + assert module.resolve_conversation_retry_user(7, "owner", 20, 4, "Same", + [{"object_name": "owned.png", "url": "new"}]) == (20, 4) + create.assert_not_called() + lock.assert_called_once_with(session, "conversation-turn-limit:7") + target_sql = str(session.scalar.call_args_list[1].args[0]) + for field in ["conversation_id", "message_id", "created_by", "message_role", "delete_flag"]: + assert field in target_sql + + +@pytest.mark.parametrize("failure", ["owner", "target", "index", "query", "attachments", "answer"]) +def test_invalid_retry_never_inserts_or_falls_back_to_new_turn(store, failure): + module, session, _lock, create = store + target = SimpleNamespace(message_id=20, message_index=4, message_content="Same", minio_files=[]) + if failure == "index": + target.message_index = 6 + if failure == "query": + target.message_content = "Another question" + if failure == "attachments": + target.minio_files = [{"object_name": "other.png"}] + session.scalar.side_effect = [None if failure == "owner" else 7, + None if failure == "target" else target, + None if failure == "answer" else 21] + with pytest.raises((module.ConversationNotFoundError, module.AppException)): + module.resolve_conversation_retry_user(7, "owner", 20, 4, "Same", []) + create.assert_not_called() diff --git a/test/backend/database/test_conversation_history_stopped.py b/test/backend/database/test_conversation_history_stopped.py index 8ff62a7404..955bcffa09 100644 --- a/test/backend/database/test_conversation_history_stopped.py +++ b/test/backend/database/test_conversation_history_stopped.py @@ -32,10 +32,10 @@ def session(): monkeypatch.setattr(module, "get_db_session", session) monkeypatch.setattr(module, "_get_user_tenant", lambda _: {"tenant_id": "tenant"}) - def message(mid, role, content, status="completed", files=None, deleted="N"): + def message(mid, role, content, status="completed", files=None, deleted="N", index=None): with engine.begin() as connection: - connection.execute(text("INSERT INTO nexent.conversation_message_t VALUES (:id, 1, :id, :role, :body, :files, :status, :deleted)"), - {"id": mid, "role": role, "body": content, "files": json.dumps(files) if files else None, "status": status, "deleted": deleted}) + connection.execute(text("INSERT INTO nexent.conversation_message_t VALUES (:id, 1, :idx, :role, :body, :files, :status, :deleted)"), + {"id": mid, "idx": mid if index is None else index, "role": role, "body": content, "files": json.dumps(files) if files else None, "status": status, "deleted": deleted}) def unit(uid, mid, kind, content, status="completed", deleted="N"): with engine.begin() as connection: @@ -46,6 +46,20 @@ def unit(uid, mid, kind, content, status="completed", deleted="N"): engine.dispose() +def test_followup_uses_latest_retry_once_and_preserves_same_text_turn(history_db): + module, message, _ = history_db + message(1, "user", "Same", index=0) + message(2, "assistant", "First answer", index=1) + message(10, "assistant", "Third answer", index=1) + message(7, "assistant", "Second answer", index=1) + message(20, "user", "Same", index=2) + message(21, "assistant", "Independent repeated question answer", index=3) + message(22, "user", "Next", index=4) + turns = module.get_historical_context(1, 22, "owner", "tenant")["conversation_turns"] + assert [turn["user_message_id"] for turn in turns] == [1, 20] + assert [turn["assistant_final_answer"] for turn in turns] == ["Third answer", "Independent repeated question answer"] + + def test_stop_followup_keeps_original_task_and_saved_results(history_db): module, message, _ = history_db message(1, "user", "Analyze all sales") diff --git a/test/backend/services/test_conversation_runtime_boundary.py b/test/backend/services/test_conversation_runtime_boundary.py index 840a2ab64a..94f589d382 100644 --- a/test/backend/services/test_conversation_runtime_boundary.py +++ b/test/backend/services/test_conversation_runtime_boundary.py @@ -26,3 +26,30 @@ def test_request_boundary_is_private_and_cannot_be_submitted(): request._runtime_user_message_index = 10 assert "_runtime_user_message_id" not in request.model_dump() assert "_runtime_user_message_index" not in request.model_dump() + + +def test_explicit_retry_binds_the_verified_original_user_without_append(monkeypatch): + import management.services.conversation.service as module + resolve = Mock(return_value=(401, 4)) + append = Mock() + monkeypatch.setattr(module, "resolve_conversation_retry_user", resolve) + monkeypatch.setattr(module, "append_conversation_user_message", append) + request = SimpleNamespace(conversation_id=7, query="Same", minio_files=[], + retry_user_message_id=401, retry_message_index=4) + module.save_conversation_user(request, "owner", "tenant") + resolve.assert_called_once_with(7, "owner", 401, 4, "Same", []) + append.assert_not_called() + assert (request._runtime_user_message_id, request._runtime_user_message_index) == (401, 4) + + +@pytest.mark.parametrize("fields", [dict(retry_user_message_id=0), dict(retry_message_index=-1), + dict(retry_message_index=0), dict(retry_user_message_id=1), + dict(retry_user_message_id=True, conversation_id=7), + dict(retry_user_message_id="1", conversation_id=7), + dict(retry_user_message_id=1, retry_message_index=False, conversation_id=7), + dict(retry_user_message_id=1, conversation_id=7, is_debug=True)]) +def test_invalid_retry_contract_is_rejected(fields): + from contracts.agent import AgentRequest + from pydantic import ValidationError + with pytest.raises(ValidationError): + AgentRequest(query="Same", **fields) From 3338599a54b966fa766acda30674e4f22f512164 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Mon, 9 Nov 2026 20:28:45 +0800 Subject: [PATCH 17/26] fix(jiuwen): stabilize runtime and workbench creation flows Use the deployment-wide Core policy and remove per-Agent Core persistence and the unmerged selection migration. Preserve Agent models, CM and resources. Keep Jiuwen executions on a managed event loop, bridge terminal ask_user forms, normalize legacy model preferences and preserve native history units. Restore MCP creator interactions and waiting terminals, retain tool identities and Skill artifacts in history, and show generated-Agent result cards only on matching completion events. Keep draft editing as a separate action. Include regression tests, formal Cases and integration documentation. Validation: 278 targeted Python tests, 104 frontend tests, copyright and whitespace checks, and strict design/implementation asset validation passed. Real Workbench journeys on both Cores passed on the matching deployed code. --- backend/contracts/agent.py | 2 - backend/contracts/constants/environment.py | 5 + backend/infrastructure/runtime_output.py | 28 +- .../api/configuration/tenant_config.py | 2 + backend/management/db/agent/models.py | 4 - backend/management/db/agent/repository.py | 2 - .../management/db/conversation/repository.py | 13 +- .../management/services/agent/core_policy.py | 36 ++ backend/management/services/agent/creation.py | 7 +- .../services/agent/model_parameters.py | 4 +- .../services/agent/preparation/compiler.py | 4 +- .../services/agent/preparation/run.py | 3 + backend/management/services/agent/read.py | 5 +- .../management/services/agent/reasoning.py | 4 +- backend/management/services/agent/service.py | 1 - backend/management/services/agent/stream.py | 4 +- backend/management/services/agent/transfer.py | 2 - .../services/conversation/creation_history.py | 3 +- .../services/conversation/service.py | 11 +- backend/management/services/skill/creation.py | 5 +- .../services/skill/stream_parser.py | 34 +- backend/runtime/api/skill/creation.py | 1 + backend/services/nl2agent_service.py | 13 +- backend/services/nl2skill_service.py | 13 +- deploy/env/.env.example | 3 + deploy/images/dockerfiles/main/Dockerfile | 2 + .../v2.7.2_1010_agent_core_factory.sql | 8 - docs/agent-core-adapters.md | 113 ++--- .../advanced/GuardrailConfigContent.tsx | 8 +- .../[agentId]/components/agent-run-policy.tsx | 13 +- ...lector.tsx => context-manager-setting.tsx} | 22 +- .../components/debug/agent-debug.tsx | 5 +- .../conversation-thread-list-adapter.tsx | 29 +- .../adapter/remote-chat-model-adapter.ts | 15 +- .../[locale]/newchat/assistant-ui/thread.tsx | 3 +- .../providers/deploymentProvider.tsx | 7 + frontend/features/workbench/WorkbenchPage.tsx | 1 + .../workbench/components/CreationActions.tsx | 48 ++ .../components/CreationResultCards.tsx | 15 +- frontend/lib/agentCore.ts | 9 - frontend/public/locales/en/common.json | 4 +- frontend/public/locales/zh/common.json | 4 +- frontend/services/agentConfigService.ts | 2 - frontend/stores/agentStore.ts | 12 +- frontend/tests/component/agentCore.test.tsx | 93 ++-- .../tests/component/globalAgentCore.test.tsx | 57 +++ .../workbench/AgentCreationCompletion.test.ts | 131 ++++++ .../AgentCreationHistoryCard.test.ts | 126 ++++- .../ClarificationHistoryAdapter.test.ts | 61 +++ .../tests/workbench/CreationActions.test.tsx | 42 ++ .../workbench/CreationResultCards.test.tsx | 10 +- .../workbench/WorkbenchPageLifecycle.test.tsx | 36 ++ frontend/types/agentConfig.ts | 4 - .../core/agents/contracts/clarification.py | 35 ++ sdk/nexent/core/agents/contracts/config.py | 3 + .../core/agents/cores/jiuwen/adapter.py | 163 +++++-- .../core/agents/cores/jiuwen/clarification.py | 54 +++ sdk/nexent/core/agents/cores/jiuwen/events.py | 19 +- .../agents/cores/jiuwen/execution_loop.py | 145 ++++++ sdk/nexent/core/agents/cores/jiuwen/model.py | 26 +- .../cores/smolagents/output_protocol.py | 33 +- sdk/nexent/core/agents/orchestration/entry.py | 5 +- .../core/agents/prompt/clarification.py | 18 + .../compatibility/agent_parameters.py | 31 +- sdk/nexent/core/prompts/en/meta/nl2agent.yaml | 10 + sdk/nexent/core/prompts/zh/meta/nl2agent.yaml | 10 + .../cases/AGENT-CORE-GLOBAL-D3-001/case.yaml | 75 +++ .../AGENT-CORE-GLOBAL-D3-001/execution.yaml | 18 + .../AGENT-CORE-GLOBAL-D3-001/mcp_fixture.py | 57 +++ .../AGENT-CORE-GLOBAL-D3-001/test_runtime.py | 239 ++++++++++ test-e2e/cases/AGENT-CORE-UI-D4-001/case.yaml | 67 ++- .../cases/AGENT-CORE-UI-D4-001/test.spec.ts | 150 ++---- .../CHAT-MODEL-CONTROLS-D3-001/case.yaml | 4 +- .../CHAT-MODEL-CONTROLS-D3-001/execution.yaml | 2 +- .../test_runtime.py | 49 ++ .../JIUWEN-AGENT-CREATOR-D4-001/case.yaml | 86 ++++ .../execution.yaml | 17 + .../JIUWEN-AGENT-CREATOR-D4-001/fixture.py | 93 ++++ .../JIUWEN-AGENT-CREATOR-D4-001/test.spec.ts | 351 ++++++++++++++ .../JIUWEN-CLARIFICATION-D4-001/case.yaml | 98 ++++ .../execution.yaml | 16 + .../JIUWEN-CLARIFICATION-D4-001/fixture.py | 156 +++++++ .../JIUWEN-CLARIFICATION-D4-001/test.spec.ts | 435 ++++++++++++++++++ .../cases/JIUWEN-CONCURRENCY-D3-001/case.yaml | 89 ++++ .../deployed_probe.py | 195 ++++++++ .../JIUWEN-CONCURRENCY-D3-001/execution.yaml | 19 + .../test_deployed.py | 35 ++ .../JIUWEN-MODEL-BRIDGE-D3-001/case.yaml | 65 +-- .../JIUWEN-MODEL-BRIDGE-D3-001/execution.yaml | 2 + .../test_runtime.py | 44 +- test-e2e/cases/JIUWEN-OUTPUT-D3-001/case.yaml | 13 +- .../cases/JIUWEN-OUTPUT-D3-001/execution.yaml | 3 +- .../cases/JIUWEN-OUTPUT-D3-001/test_output.py | 76 +++ .../cases/JIUWEN-REAL-UI-D4-001/case.yaml | 3 +- .../cases/JIUWEN-REAL-UI-D4-001/test.spec.ts | 6 +- .../cases/JIUWEN-RESOURCES-D3-002/case.yaml | 32 +- .../JIUWEN-RESOURCES-D3-002/execution.yaml | 3 +- .../test_collaboration.py | 23 + .../cases/JIUWEN-RESOURCES-D5-003/case.yaml | 32 +- .../JIUWEN-RESOURCES-D5-003/execution.yaml | 3 +- .../test_collaboration.py | 13 + .../JIUWEN-SKILL-CREATOR-D4-001/case.yaml | 50 ++ .../execution.yaml | 15 + .../JIUWEN-SKILL-CREATOR-D4-001/fixture.py | 88 ++++ .../JIUWEN-SKILL-CREATOR-D4-001/test.spec.ts | 254 ++++++++++ .../bugs/JIUWEN-CLARIFICATION-20261105.yaml | 57 +++ .../JIUWEN-HISTORY-ENVELOPE-20261103.yaml | 44 ++ .../bugs/JIUWEN-HITL-HISTORY-20261106.yaml | 38 ++ .../bugs/JIUWEN-LOOP-OWNERSHIP-20261106.yaml | 55 +++ .../bugs/JIUWEN-MAIN-SDK-20261102.yaml | 45 ++ .../bugs/JIUWEN-NL2AGENT-CARD-20261109.yaml | 52 +++ .../bugs/JIUWEN-SKILL-HISTORY-20261109.yaml | 36 ++ .../bugs/LEGACY-AGENT-REASONING-20261106.yaml | 48 ++ .../AGENT-CORE-GLOBAL-20261105.yaml | 41 ++ .../NL2AGENT-RESULT-GATE-20261109.yaml | 38 ++ test-e2e/features/AGENT-CORE-CONFIG/cases.md | 2 + .../features/AGENT-CORE-CONFIG/feature.yaml | 24 +- .../features/CHAT-MODEL-CONTROLS/feature.yaml | 2 +- .../features/JIUWEN-MODEL-BRIDGE/cases.md | 1 + .../features/JIUWEN-MODEL-BRIDGE/feature.yaml | 24 +- test-e2e/features/JIUWEN-OUTPUT/cases.md | 1 + test-e2e/features/JIUWEN-RESOURCES/cases.md | 1 + .../features/JIUWEN-RESOURCES/feature.yaml | 30 +- .../infra/automation/jiuwen_bridge_fixture.py | 44 +- .../infra/automation/jiuwen_bridge_runtime.py | 5 +- .../jiuwen_collaboration_runtime.py | 4 +- .../automation/jiuwen_parallel_runtime.py | 178 +++++++ test/backend/agents/test_create_agent_info.py | 5 +- test/backend/agents/test_global_agent_core.py | 161 +++++++ .../agents/test_jiuwen_creator_results.py | 201 ++++++++ test/backend/app/test_tenant_config_app.py | 4 +- .../deployment/test_main_image_sdk_sources.py | 15 + .../services/test_agent_core_creation.py | 19 +- ...st_agent_legacy_parameter_compatibility.py | 137 ++++++ .../services/test_agent_model_parameters.py | 11 +- .../test_agent_transfer_core_context.py | 20 +- .../services/test_clarification_response.py | 27 ++ .../test_nl2skill_history_protocol.py | 74 +++ test/backend/services/test_nl2skill_stream.py | 13 +- .../utils/test_runtime_history_projection.py | 94 ++++ test/backend/utils/test_runtime_output.py | 39 ++ .../core/agents/test_jiuwen_clarification.py | 145 ++++++ .../test_jiuwen_context_terminal_guard.py | 4 + .../core/agents/test_jiuwen_execution_loop.py | 237 ++++++++++ 144 files changed, 5969 insertions(+), 624 deletions(-) create mode 100644 backend/management/services/agent/core_policy.py delete mode 100644 deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql rename frontend/app/[locale]/agents/[agentId]/components/{agent-core-selector.tsx => context-manager-setting.tsx} (58%) delete mode 100644 frontend/lib/agentCore.ts create mode 100644 frontend/tests/component/globalAgentCore.test.tsx create mode 100644 frontend/tests/workbench/AgentCreationCompletion.test.ts create mode 100644 sdk/nexent/core/agents/cores/jiuwen/clarification.py create mode 100644 sdk/nexent/core/agents/cores/jiuwen/execution_loop.py create mode 100644 test-e2e/cases/AGENT-CORE-GLOBAL-D3-001/case.yaml create mode 100644 test-e2e/cases/AGENT-CORE-GLOBAL-D3-001/execution.yaml create mode 100644 test-e2e/cases/AGENT-CORE-GLOBAL-D3-001/mcp_fixture.py create mode 100644 test-e2e/cases/AGENT-CORE-GLOBAL-D3-001/test_runtime.py create mode 100644 test-e2e/cases/JIUWEN-AGENT-CREATOR-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-AGENT-CREATOR-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-AGENT-CREATOR-D4-001/fixture.py create mode 100644 test-e2e/cases/JIUWEN-AGENT-CREATOR-D4-001/test.spec.ts create mode 100644 test-e2e/cases/JIUWEN-CLARIFICATION-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-CLARIFICATION-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-CLARIFICATION-D4-001/fixture.py create mode 100644 test-e2e/cases/JIUWEN-CLARIFICATION-D4-001/test.spec.ts create mode 100644 test-e2e/cases/JIUWEN-CONCURRENCY-D3-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-CONCURRENCY-D3-001/deployed_probe.py create mode 100644 test-e2e/cases/JIUWEN-CONCURRENCY-D3-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-CONCURRENCY-D3-001/test_deployed.py create mode 100644 test-e2e/cases/JIUWEN-SKILL-CREATOR-D4-001/case.yaml create mode 100644 test-e2e/cases/JIUWEN-SKILL-CREATOR-D4-001/execution.yaml create mode 100644 test-e2e/cases/JIUWEN-SKILL-CREATOR-D4-001/fixture.py create mode 100644 test-e2e/cases/JIUWEN-SKILL-CREATOR-D4-001/test.spec.ts create mode 100644 test-e2e/changes/bugs/JIUWEN-CLARIFICATION-20261105.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-HISTORY-ENVELOPE-20261103.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-HITL-HISTORY-20261106.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-LOOP-OWNERSHIP-20261106.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-MAIN-SDK-20261102.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-NL2AGENT-CARD-20261109.yaml create mode 100644 test-e2e/changes/bugs/JIUWEN-SKILL-HISTORY-20261109.yaml create mode 100644 test-e2e/changes/bugs/LEGACY-AGENT-REASONING-20261106.yaml create mode 100644 test-e2e/changes/requirements/AGENT-CORE-GLOBAL-20261105.yaml create mode 100644 test-e2e/changes/requirements/NL2AGENT-RESULT-GATE-20261109.yaml create mode 100644 test-e2e/infra/automation/jiuwen_parallel_runtime.py create mode 100644 test/backend/agents/test_global_agent_core.py create mode 100644 test/backend/agents/test_jiuwen_creator_results.py create mode 100644 test/backend/deployment/test_main_image_sdk_sources.py create mode 100644 test/backend/services/test_agent_legacy_parameter_compatibility.py create mode 100644 test/backend/services/test_nl2skill_history_protocol.py create mode 100644 test/sdk/core/agents/test_jiuwen_clarification.py create mode 100644 test/sdk/core/agents/test_jiuwen_execution_loop.py diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index ed727cdac0..e98ca73682 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -388,7 +388,6 @@ class RelatedAgentInfo(BaseModel): class AgentInfoRequest(BaseModel): - agent_core: Optional[Literal["smolagents", "jiuwen"]] = None agent_id: Optional[int] = None name: Optional[str] = None display_name: Optional[str] = None @@ -451,7 +450,6 @@ class AgentIDRequest(BaseModel): class ExportAndImportAgentInfo(BaseModel): - agent_core: Literal["smolagents", "jiuwen"] = "smolagents" enable_context_manager: bool = True agent_id: int tenant_id: Optional[str] = None diff --git a/backend/contracts/constants/environment.py b/backend/contracts/constants/environment.py index 38d55dcdcf..983b6f2560 100644 --- a/backend/contracts/constants/environment.py +++ b/backend/contracts/constants/environment.py @@ -13,6 +13,11 @@ _ENVIRONMENT_LOADED = load_dotenv(override=False) _CONFIG_DIRECTORY = Path(__file__).resolve().parents[2] / 'configs' +# One deployment owns the execution Core; persisted Agent fields are legacy metadata. +NEXENT_AGENT_CORE = os.getenv('NEXENT_AGENT_CORE', 'smolagents').strip().lower() +if NEXENT_AGENT_CORE not in {'smolagents', 'jiuwen'}: + raise ValueError('NEXENT_AGENT_CORE must be smolagents or jiuwen') + # Operations service configuration OPS_PORT = parse_port(os.getenv('OPS_PORT'), 'OPS_PORT', 5016) diff --git a/backend/infrastructure/runtime_output.py b/backend/infrastructure/runtime_output.py index 92367f4857..4f2ce514da 100644 --- a/backend/infrastructure/runtime_output.py +++ b/backend/infrastructure/runtime_output.py @@ -12,6 +12,24 @@ def is_runtime_event(event): and event.get("agent_core") == "jiuwen") +def _restore_duplicate_runtime_envelope(event): + """Repair the old non-text double wrapper only when all event metadata agrees.""" + if event.get("type") not in {"step_count", "tool", "tool_result", "step_observation", "run_terminal"}: + return event + try: + nested = json.loads(event.get("content", "")) + except (ValueError, TypeError): + return event + if ( + isinstance(nested, dict) + and is_runtime_event(nested) + and {key: value for key, value in nested.items() if key != "content"} + == {key: value for key, value in event.items() if key != "content"} + ): + return nested + return event + + def project_runtime_history(content, units): """Clean proven runtime records for display without rewriting audit data or user messages.""" parsed = [] @@ -37,13 +55,21 @@ def final(text): for unit, event in zip(units, parsed): item = dict(unit) if event: + if event.get("type") == item.get("unit_type"): + event = _restore_duplicate_runtime_envelope(event) event = dict(event) kind = event.get("type") if kind in {"model_output", "model_output_deep_thinking"}: key = (event.get("turn_id"), event.get("invocation_id"), event.get("block_id"), kind) event["content"] = filters.setdefault(key, PublicTextFilter()).push(event.get("content", "")) elif kind == "run_terminal" and event.get("status") == "completed": - event["final_content"], invalid = final(event.get("final_content", "")) + # A terminal clarification intentionally has no final answer. Keep + # its explicit state without weakening normal answer validation. + waiting = (event.get("end_reason") == "awaiting_input" + and event.get("final_content") == "" + and event.get("final_block_ids") == [] + and event.get("error") is None) + event["final_content"], invalid = ("", False) if waiting else final(event.get("final_content", "")) if invalid: event.update(status="failed", final_block_ids=[], end_reason="JIUWEN_FINAL_OUTPUT_INVALID", error={"category": "JIUWEN_FINAL_OUTPUT_INVALID", "scope": "protocol", diff --git a/backend/management/api/configuration/tenant_config.py b/backend/management/api/configuration/tenant_config.py index 0d7485e7f9..d040906e09 100644 --- a/backend/management/api/configuration/tenant_config.py +++ b/backend/management/api/configuration/tenant_config.py @@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse from contracts.constants.environment import APP_VERSION, DEPLOYMENT_VERSION, ENABLE_AIDP_KNOWLEDGE, ENABLE_AGENT_WORKBENCH, HIDE_HOME_PAGE +from contracts.constants.environment import NEXENT_AGENT_CORE logger = logging.getLogger("tenant_config_app") router = APIRouter(prefix="/tenant_config") @@ -23,6 +24,7 @@ def get_deployment_version(): content={ "deployment_version": DEPLOYMENT_VERSION, "app_version": APP_VERSION, + "agent_core": NEXENT_AGENT_CORE, "enable_aidp_knowledge": ENABLE_AIDP_KNOWLEDGE, "enable_agent_workbench": ENABLE_AGENT_WORKBENCH, "hide_home_page": HIDE_HOME_PAGE, diff --git a/backend/management/db/agent/models.py b/backend/management/db/agent/models.py index 9ceb2c0d0f..eef9d8b6cb 100644 --- a/backend/management/db/agent/models.py +++ b/backend/management/db/agent/models.py @@ -57,10 +57,6 @@ class AgentInfo(TableBase): author = Column(String(100), doc="Agent author") model_ids = Column( ARRAY(Integer), doc="List of model IDs, foreign key references to model_record_t.model_id, max 5 models") - agent_core = Column( - String(30), nullable=False, default="smolagents", server_default=text("'smolagents'"), - comment="Execution core for this immutable agent version: smolagents or jiuwen", - ) max_steps = Column(Integer, doc="Maximum number of steps") prompt = Column( Text, diff --git a/backend/management/db/agent/repository.py b/backend/management/db/agent/repository.py index 6785afbd68..44145243e9 100644 --- a/backend/management/db/agent/repository.py +++ b/backend/management/db/agent/repository.py @@ -299,7 +299,6 @@ def create_agent(agent_info, tenant_id: str, user_id: str): :return: Created agent object """ info_with_metadata = dict(agent_info) - info_with_metadata.setdefault("agent_core", "smolagents") info_with_metadata.setdefault("max_steps", 15) info_with_metadata.setdefault("is_main_agent", True) info_with_metadata.setdefault("verification_config", None) @@ -329,7 +328,6 @@ def create_agent(agent_info, tenant_id: str, user_id: str): # Directly extract agent_id and return as dict result = { "agent_id": new_agent.agent_id, - "agent_core": new_agent.agent_core, "tenant_id": new_agent.tenant_id, "name": new_agent.name, "system_key": getattr(new_agent, "system_key", None), diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 5d6c326717..1fc07057f5 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -471,7 +471,7 @@ def _resolve_clarification_response(session, conversation_id, user_id, role, raw if source is None or source.message_role != 'assistant' or source.status != 'completed': raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Clarification is no longer available") units = session.execute(select( - ConversationMessageUnit.unit_content, ConversationMessageUnit.unit_index, + ConversationMessageUnit.unit_content, ConversationMessageUnit.unit_index, ConversationMessageUnit.unit_type, ).where( ConversationMessageUnit.conversation_id == conversation_id, ConversationMessageUnit.message_id == source.message_id, @@ -483,7 +483,16 @@ def _resolve_clarification_response(session, conversation_id, user_id, role, raw if response.unit_index is not None and unit.unit_index != response.unit_index: continue try: - if normalize_form(json.loads(unit.unit_content)) == response.form: + saved = json.loads(unit.unit_content) + if (isinstance(saved, dict) and type(saved.get('schema_version')) is int + and saved['schema_version'] == 1 and saved.get('runtime_mode') == 'native_agent' + and saved.get('type') == unit.unit_type == 'human_interaction' + and isinstance(saved.get('turn_id'), str) and saved['turn_id'].strip() + and isinstance(saved.get('content'), str)): + # Only the owned persisted event may carry a single public envelope. + # Client forms remain strict data; nested envelopes are not interpreted. + saved = json.loads(saved['content']) + if normalize_form(saved) == response.form: matches.append(unit) except (TypeError, ValueError): continue diff --git a/backend/management/services/agent/core_policy.py b/backend/management/services/agent/core_policy.py new file mode 100644 index 0000000000..7cc7feba7f --- /dev/null +++ b/backend/management/services/agent/core_policy.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Compile deployment policy into detached, application-owned Agent configs.""" + +from contracts.constants.environment import NEXENT_AGENT_CORE +from nexent.core.agents.contracts.adapter import UnsupportedAgentCoreConfig + + +def apply_agent_core_policy(config): + """Keep saved definitions untouched; normalize only this run's Core options.""" + config.agent_core = NEXENT_AGENT_CORE + if NEXENT_AGENT_CORE == "jiuwen": + guardrail = config.verification_config.guardrail_config + if guardrail is not None and guardrail.enabled: + raise UnsupportedAgentCoreConfig("Jiuwen does not support the configured guardrail policy") + config.enable_protocol_repair_retry = False + config.provide_run_summary = False + config.verification_config = config.verification_config.model_copy(update={"enabled": False}) + for child in config.worker_agents: + apply_agent_core_policy(child) + return config + + +def prepare_creator_core(run_info, *, tenant_id, user_id=None, user_context=None): + """Resolve creator identity and the final model snapshot before execution.""" + run_info.tenant_id = tenant_id + run_info.user_id = user_id + run_info.user_context = user_context or {"tenant_id": tenant_id, "user_id": user_id} + apply_agent_core_policy(run_info.agent_config) + if run_info.agent_config.agent_core == "jiuwen": + from management.services.model.runtime_options import compile_bound_model_profile + + model = next(m for m in run_info.model_config_list if m.cite_name == run_info.agent_config.model_name) + model.prompt_cache = None + model.resolved_profile = compile_bound_model_profile(model, run_info.agent_config) + return run_info diff --git a/backend/management/services/agent/creation.py b/backend/management/services/agent/creation.py index 6921b18666..80783dccf0 100644 --- a/backend/management/services/agent/creation.py +++ b/backend/management/services/agent/creation.py @@ -5,6 +5,8 @@ import json from contracts.constants.constants import LANGUAGE +from contracts.constants.environment import NEXENT_AGENT_CORE +from management.services.agent.core_policy import apply_agent_core_policy from nexent.core.agents.contracts.config import AgentConfig, ToolConfig from nexent.core.agents.context import ContextItemInput, ContextItemType from nexent.core.agents.prompt.meta import compose_nl2agent_system @@ -43,6 +45,7 @@ def build_nl2agent_system_prompt( "wrapper_name": wrapper_name, "save_tool_name": save_tool_name, "max_results": max_results, + "target_core": NEXENT_AGENT_CORE, }, ) @@ -64,7 +67,8 @@ def create_nl2agent_agent_config(language: str, snapshot: dict | None = None) -> source="local", ) ) - return AgentConfig( + config = AgentConfig( + response_mode="python_workflow", agent_id=snapshot["agent_id"] if snapshot is not None else None, version_no=snapshot["version_no"] if snapshot is not None else None, runtime_ref=(f"agent:{snapshot['agent_id']}:v{snapshot['version_no']}" if snapshot is not None else None), @@ -93,3 +97,4 @@ def create_nl2agent_agent_config(language: str, snapshot: dict | None = None) -> ], enable_planning=False, ) + return apply_agent_core_policy(config) diff --git a/backend/management/services/agent/model_parameters.py b/backend/management/services/agent/model_parameters.py index 9d66448a7b..e352048699 100644 --- a/backend/management/services/agent/model_parameters.py +++ b/backend/management/services/agent/model_parameters.py @@ -9,7 +9,7 @@ def merge_agent_model_parameters(profile, entry): """Read a current/historical entry at the boundary, then apply canonical values.""" from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry - return _apply_agent_parameters(profile, read_agent_parameter_entry(entry)) + return _apply_agent_parameters(profile, read_agent_parameter_entry(entry, profile=profile)) def _apply_agent_parameters(profile, entry): @@ -32,7 +32,7 @@ def merge_runtime_model_parameters(profile, entry, preferences): if not isinstance(preferences, dict): from contracts.errors.exceptions import ModelOutletValidationError raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") - canonical = read_agent_parameter_entry(entry) + canonical = read_agent_parameter_entry(entry, profile=profile) from nexent.core.model_service.profiles.protocol_parameters import without_outlet_token_limits preferences = without_outlet_token_limits(preferences) agent_view = _apply_agent_parameters(profile, canonical) diff --git a/backend/management/services/agent/preparation/compiler.py b/backend/management/services/agent/preparation/compiler.py index e8432235f2..038b9572bd 100644 --- a/backend/management/services/agent/preparation/compiler.py +++ b/backend/management/services/agent/preparation/compiler.py @@ -20,6 +20,7 @@ ) from management.db.model.repository import get_model_by_model_id from management.services.agent.context import build_context_inputs +from management.services.agent.core_policy import apply_agent_core_policy from management.services.agent.preparation.resources import ( _build_effective_knowledge_base_summary, _get_external_a2a_agents, @@ -709,7 +710,6 @@ async def create_agent_config( ) agent_config = AgentConfig( - agent_core=agent_info.get("agent_core") or "smolagents", name="undefined" if agent_info["name"] is None else agent_info["name"], display_name=agent_info.get("display_name"), description="undefined" @@ -741,7 +741,7 @@ async def create_agent_config( agent_config.runtime_ref = f"agent:{agent_id}:v{int(version_no)}" agent_config.display_name = agent_info.get("display_name") or agent_config.name agent_config.origin = "PERSISTED" - return agent_config + return apply_agent_core_policy(agent_config) def _select_agent_model_id( diff --git a/backend/management/services/agent/preparation/run.py b/backend/management/services/agent/preparation/run.py index f0f869d275..01ff7421bb 100644 --- a/backend/management/services/agent/preparation/run.py +++ b/backend/management/services/agent/preparation/run.py @@ -425,6 +425,7 @@ async def create_agent_run_info( if selected is None: raise ValueError("MODEL_OUTLET_NOT_FOUND") selected.resolved_profile = compile_bound_model_profile(selected, agent_config) + selected.prompt_cache = None agent_run_info = build_run_info( query=final_query, @@ -568,5 +569,7 @@ def _capture_child_model_configs(config, models, tenant_id): selected.resolved_profile = compile_model_runtime_profile(selected, child) elif child.agent_core == "jiuwen": selected.resolved_profile = compile_bound_model_profile(selected, child) + if child.agent_core == "jiuwen": + selected.prompt_cache = None child.runtime_model_config = selected.model_copy(deep=True) _capture_child_model_configs(child, models, tenant_id) diff --git a/backend/management/services/agent/read.py b/backend/management/services/agent/read.py index 910d2af83e..7209ab87b6 100644 --- a/backend/management/services/agent/read.py +++ b/backend/management/services/agent/read.py @@ -46,12 +46,15 @@ def project_agent_models( overrides = agent.get("model_params_override") if isinstance(overrides, dict): from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry + from nexent.core.model_service.profiles.outlet_storage import decode_resolved_model_profile + from nexent.core.model_service.registry import model_service_references current = dict(overrides) for model_id, record in records: if (record and record.get("model_type") in {"llm", "chat"} and record.get("profile_schema_version") is not None and str(model_id) in current): - current[str(model_id)] = read_agent_parameter_entry(current[str(model_id)]) + profile = decode_resolved_model_profile(record, registered_references=model_service_references()) + current[str(model_id)] = read_agent_parameter_entry(current[str(model_id)], profile=profile) fields["model_params_override"] = current return AgentModelProjection( fields=fields, diff --git a/backend/management/services/agent/reasoning.py b/backend/management/services/agent/reasoning.py index 3822fb41e4..63038d9383 100644 --- a/backend/management/services/agent/reasoning.py +++ b/backend/management/services/agent/reasoning.py @@ -144,9 +144,9 @@ def snapshot_agent_reasoning_config( if not entry: continue from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry - entry = read_agent_parameter_entry(entry) + profile = decode_resolved_model_profile(model_info, registered_references=model_service_references()) + entry = read_agent_parameter_entry(entry, profile=profile) if entry["parameter_values"] or entry.get("context_window_tokens") is not None or entry.get("custom_overrides") is not None: - profile = decode_resolved_model_profile(model_info, registered_references=model_service_references()) merge_agent_model_parameters(profile, entry) override_map[model_key] = entry continue diff --git a/backend/management/services/agent/service.py b/backend/management/services/agent/service.py index a2d6778553..a3d18d0779 100644 --- a/backend/management/services/agent/service.py +++ b/backend/management/services/agent/service.py @@ -712,7 +712,6 @@ async def update_agent_info_impl( user_group_ids = _get_user_group_ids(user_id, tenant_id) created = create_agent( agent_info={ - "agent_core": request.agent_core or "smolagents", "name": request.name, "display_name": request.display_name, "description": request.description, diff --git a/backend/management/services/agent/stream.py b/backend/management/services/agent/stream.py index c900cd5d62..eaabbb0051 100644 --- a/backend/management/services/agent/stream.py +++ b/backend/management/services/agent/stream.py @@ -30,7 +30,9 @@ def finalize_buffered_unit_fragments(message_units: list[dict[str, Any]]) -> int unit["content"] = content unit["unit_content"] = content envelope = unit.pop("_runtime_envelope", None) - if envelope is not None: + # Non-mergeable units were already serialized when buffered. Only joined + # text fragments need their original envelope reconstructed here. + if envelope is not None and fragments is not None: envelope["content"] = unit["content"] unit["content"] = unit["unit_content"] = json.dumps(envelope, ensure_ascii=False) finalized_bytes += len(str(unit.get("unit_content", "")).encode("utf-8")) diff --git a/backend/management/services/agent/transfer.py b/backend/management/services/agent/transfer.py index 01eef46e03..685fbd166e 100644 --- a/backend/management/services/agent/transfer.py +++ b/backend/management/services/agent/transfer.py @@ -431,7 +431,6 @@ async def export_agent_by_agent_id( ) agent_info = ExportAndImportAgentInfo( - agent_core=agent_info.get("agent_core") or "smolagents", enable_context_manager=bool(agent_info.get("enable_context_manager", True)), agent_id=agent_id, tenant_id=agent_info["tenant_id"], @@ -608,7 +607,6 @@ async def import_agent_by_agent_id( user_group_ids = _get_user_group_ids(user_id, tenant_id) new_agent = create_agent( agent_info={ - "agent_core": import_agent_info.agent_core, "enable_context_manager": import_agent_info.enable_context_manager, "name": agent_names["name"], "display_name": agent_names["display_name"], diff --git a/backend/management/services/conversation/creation_history.py b/backend/management/services/conversation/creation_history.py index 53dbd8e002..c55a280dea 100644 --- a/backend/management/services/conversation/creation_history.py +++ b/backend/management/services/conversation/creation_history.py @@ -8,6 +8,7 @@ from contracts.conversation import MessageRequest, MessageUnit from contracts.agent import WorkbenchSessionConfig +from infrastructure.runtime_output import is_runtime_event from management.db.conversation.repository import get_conversation_history from management.services.conversation.creator_targets import bind_creator_target from management.services.conversation.service import ( @@ -155,7 +156,7 @@ async def persist_creation_stream( if isinstance(raw_content, str) else json.dumps(raw_content, ensure_ascii=False) ) - if kind in { + if is_runtime_event(chunk) or kind in { "skill_body", "file_content", "agent_new_run", "target_files", "model_attempt_control", "done", }: diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index d7970b0dda..41fb33c91f 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -33,7 +33,7 @@ from management.services.configuration.tenant.settings import tenant_config_manager from nexent.core.agents.prompt.auxiliary import compose_auxiliary_prompt from management.services.model.text import remove_think_blocks -from infrastructure.runtime_output import project_runtime_history +from infrastructure.runtime_output import is_runtime_event, project_runtime_history logger = logging.getLogger("conversation_management_service") monitoring_manager = get_monitoring_manager() @@ -892,6 +892,12 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List message_item['minio_files'] = msg['minio_files'] else: # Assistant message: message is an array, need to process search_content_placeholder + config = history_data.get("workbench_config") + if (history_data.get("agent_system_key") == "nl2skill_main" + or isinstance(config, dict) and config.get("mode") == "skill_create"): + from management.services.skill.stream_parser import project_skill_history_units + + message_units = project_skill_history_units(message_units) message_content, message_units, invalid_runtime_answer = project_runtime_history( message_content, message_units ) @@ -949,7 +955,8 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List tool_data = json.loads(unit_content) except (json.JSONDecodeError, TypeError): tool_data = None - if isinstance(tool_data, dict) and 'content' in tool_data: + if (isinstance(tool_data, dict) and 'content' in tool_data + and not is_runtime_event(tool_data)): processed_unit['content'] = tool_data.get('content', '') processed_unit['tool_name'] = tool_data.get('tool_name') processed_unit['tool_arguments'] = tool_data.get('tool_arguments') diff --git a/backend/management/services/skill/creation.py b/backend/management/services/skill/creation.py index 08904c4e0a..252d28fdb8 100644 --- a/backend/management/services/skill/creation.py +++ b/backend/management/services/skill/creation.py @@ -4,6 +4,7 @@ from nexent.core.agents.contracts.config import AgentConfig from nexent.core.agents.context import ContextItemInput, ContextItemType +from management.services.agent.core_policy import apply_agent_core_policy NL2SKILL_NAME = "nl2skill_main" @@ -18,7 +19,8 @@ def create_nl2skill_agent_config( if not system_prompt.strip(): raise ValueError("Skill creation system prompt must not be empty") - return AgentConfig( + config = AgentConfig( + response_mode="artifact", agent_id=system_ref.agent_id if system_ref else None, version_no=0 if system_ref else None, runtime_ref=f"agent:{system_ref.agent_id}:v0" if system_ref else None, @@ -55,3 +57,4 @@ def create_nl2skill_agent_config( ], enable_planning=False, ) + return apply_agent_core_policy(config) diff --git a/backend/management/services/skill/stream_parser.py b/backend/management/services/skill/stream_parser.py index b7d7d2316d..d854babcdf 100644 --- a/backend/management/services/skill/stream_parser.py +++ b/backend/management/services/skill/stream_parser.py @@ -3,6 +3,7 @@ """Request-local routing of Skill observer output and attempt boundaries.""" from copy import deepcopy +import json from typing import Any from management.services.skill.classifier import ContentClassifier @@ -18,6 +19,34 @@ ) +def _business_event(event: dict[str, Any]) -> dict[str, Any]: + """Keep creator provenance without claiming to be a native runtime envelope.""" + if (type(event.get("schema_version")) is int and event["schema_version"] == 1 + and event.get("runtime_mode") == "native_agent" + and isinstance(event.get("turn_id"), str) and event["turn_id"]): + return {key: value for key, value in event.items() if key not in {"schema_version", "runtime_mode"}} + return event + + +def project_skill_history_units(units: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Read old creator artifact wrappers without changing their saved contents.""" + projected = [] + for unit in units: + item = unit + if unit.get("unit_type") in {"skill_body", "file_content"}: + try: + event = json.loads(unit.get("unit_content", "")) + except (ValueError, TypeError): + event = None + if (isinstance(event, dict) and event.get("type") == unit["unit_type"] + and isinstance(event.get("content"), str)): + business = _business_event(event) + if business is not event: + item = {**unit, "unit_content": json.dumps(business, ensure_ascii=False)} + projected.append(item) + return projected + + class SkillStreamParser: """Classify deltas exactly once and restore parser state on model rollback.""" @@ -39,9 +68,12 @@ def _classify(self, chunk: dict[str, Any]) -> list[dict[str, Any]]: def flush(self) -> list[dict[str, Any]]: """Flush with the producing chunk's metadata, before attempt commit.""" - return [{**self.source, **event} for event in self.classifier.flush()] + return [_business_event({**self.source, **event}) for event in self.classifier.flush()] def process(self, chunk: dict[str, Any]) -> list[dict[str, Any]]: + return [_business_event(event) for event in self._process(chunk)] + + def _process(self, chunk: dict[str, Any]) -> list[dict[str, Any]]: kind = chunk.get("type") # Provider reasoning can quote the entire output protocol. It is never # a generated file and must not advance the answer classifier state. diff --git a/backend/runtime/api/skill/creation.py b/backend/runtime/api/skill/creation.py index 72809bf63c..ab5c03075c 100644 --- a/backend/runtime/api/skill/creation.py +++ b/backend/runtime/api/skill/creation.py @@ -44,6 +44,7 @@ async def nl2skill_run_api( stream_factory=lambda effective, ref: create_nl2skill_stream( request=effective, tenant_id=tenant_id, language=request.language or user_language or "zh", system_ref=ref, + user_id=user_id, ), ) return StreamingResponse(stream, media_type="text/event-stream", diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index 5769d7aac8..76453883d2 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -1564,6 +1564,10 @@ async def build_nl2agent_run_info( sandbox_config=None, redis_client=None, ) + from management.services.agent.core_policy import prepare_creator_core + + prepare_creator_core(run_info, tenant_id=tenant_id, user_id=user_id, + user_context={"tenant_id": tenant_id, "user_id": user_id}) run_info.context_input = build_authorized_context_input(run_info) return run_info @@ -1606,10 +1610,15 @@ async def generate() -> AsyncIterator[str]: thread_manager=runtime_thread_manager, ): if boundary_delivered: - continue + try: + kind = json.loads(chunk).get("type") + except (AttributeError, TypeError, json.JSONDecodeError): + continue + if kind not in {"tool_result", "run_terminal", "done", "error"}: + continue yield f"data: {chunk}\n\n" try: - boundary_delivered = ( + boundary_delivered = boundary_delivered or ( json.loads(chunk).get("type") == ProcessType.NL2A.value ) except (AttributeError, TypeError, json.JSONDecodeError): diff --git a/backend/services/nl2skill_service.py b/backend/services/nl2skill_service.py index 526c1b3026..2070c971b5 100644 --- a/backend/services/nl2skill_service.py +++ b/backend/services/nl2skill_service.py @@ -204,6 +204,8 @@ async def build_nl2skill_run_info( tenant_id: str, language: str, system_ref=None, + *, + user_id: str | None = None, ) -> AgentRunInfo: """Build all request-scoped objects for one NL2Skill turn.""" @@ -275,6 +277,9 @@ async def build_nl2skill_run_info( sandbox_config=None, redis_client=None, ) + from management.services.agent.core_policy import prepare_creator_core + + prepare_creator_core(run_info, tenant_id=tenant_id, user_id=user_id) run_info.context_input = build_authorized_context_input( run_info, historical_context={"conversation_turns": _skill_history_turns(run_info.history)}, ) @@ -301,10 +306,12 @@ async def create_nl2skill_stream( tenant_id: str, language: str, system_ref=None, + *, + user_id: str | None = None, ) -> AsyncIterator[str]: """Create the SSE payload stream for one ephemeral NL2Skill turn.""" - run_info = await build_nl2skill_run_info(request, tenant_id, language, system_ref=system_ref) + run_info = await build_nl2skill_run_info(request, tenant_id, language, system_ref=system_ref, user_id=user_id) target_files = _extract_target_files(request.query, request.draft_snapshot) target_file_set = set(target_files) @@ -352,6 +359,10 @@ def is_allowed_write(event: dict[str, Any]) -> bool: if is_allowed_write(event): yield serialize(event) + result = getattr(run_info, "run_result", None) + if result is not None and result.status != "completed": + yield serialize({"type": "error", "content": "NL2Skill execution did not complete."}) + return for event in parser.flush(): if is_allowed_write(event): yield serialize(event) diff --git a/deploy/env/.env.example b/deploy/env/.env.example index f628b9669e..e78b47e727 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -6,6 +6,9 @@ TOKEN=token # ===== Non-essential Configs (Modify if you know what you are doing) ===== +# Execution Core for all local Agents, creators and Workbench runs. Restart services after changing. +NEXENT_AGENT_CORE=smolagents + # Frontend and backend container timezone (IANA name, e.g. Asia/Shanghai or UTC). TZ=Asia/Shanghai diff --git a/deploy/images/dockerfiles/main/Dockerfile b/deploy/images/dockerfiles/main/Dockerfile index ea68ad7401..8ba6c21b47 100644 --- a/deploy/images/dockerfiles/main/Dockerfile +++ b/deploy/images/dockerfiles/main/Dockerfile @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + FROM python:3.11-slim AS base ARG MIRROR ARG APT_MIRROR diff --git a/deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql b/deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql deleted file mode 100644 index 52d08daa0c..0000000000 --- a/deploy/sql/migrations/v2.7.2_1010_agent_core_factory.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Migration kind: REQUIRED_SCHEMA --- Required for: Optional native Agent Core selection on every Agent version. --- Preserve the execution core on each draft and published Agent version. -ALTER TABLE nexent.ag_tenant_agent_t - ADD COLUMN IF NOT EXISTS agent_core VARCHAR(30) NOT NULL DEFAULT 'smolagents'; - -COMMENT ON COLUMN nexent.ag_tenant_agent_t.agent_core IS - 'Execution core for this immutable agent version: smolagents or jiuwen'; diff --git a/docs/agent-core-adapters.md b/docs/agent-core-adapters.md index 1185d26bd6..8380489cd2 100644 --- a/docs/agent-core-adapters.md +++ b/docs/agent-core-adapters.md @@ -1,108 +1,61 @@ -# 可选 Agent Core:接入与验证说明 +# Agent Core 接入与部署配置 -## 2026-10-16 当前接入方式(M6) +Nexent 保存智能体的业务配置,后端统一工厂选择 Core Adapter。Jiuwen Adapter 将配置、可信模型出口和授权资源转换为 Jiuwen 可以运行的对象,调用其 Runner,再把过程和结果转换为公共输出。SDK 目录采用 contracts、factory、orchestration、resources、output、context、sandbox 和 cores/{smolagents,jiuwen} 分层;models 目录位置不变,不恢复旧别名。 -Nexent 继续保存智能体配置,Factory 选择 Jiuwen Adapter,Jiuwen Runner 负责推理循环。模型统一经 ModelService 发送;原生工具调用和受控文本协议共用授权资源入口,不运行 CodeAgent 循环。 +## 系统级 Core 选择 -- 依赖固定 `openjiuwen==0.1.19`;SDK 安装 `nexent[jiuwen]`。公共模型服务集成至固定 6dc5ed0bb 基线的必要增量,不表示重新核验了 Jiuwen 最新发布。 -- 原生/text 均支持已授权工具、MCP、Skills 和显式 parallel;文本执行使用现有 Python/沙箱及远端回调上下文。MCP 的保存参数、隐藏身份、超时/会话所有权沿用 Nexent;Skills 按 root+name 白名单读取,脚本运行在配置指定的后端。 -- 页面切换 Core 只修改 core,保留工具、MCP、Skills、协作绑定、模型和所有策略值。Jiuwen 不支持的选项仍禁用编辑并在运行前明确拒绝;需要先在 smolagents 下关闭尚未支持的能力,再运行 Jiuwen。 -- 子智能体逐调用经工厂选择自身 Core,按自身版本绑定模型出口/参数;Jiuwen↔smolagents 和 Jiuwen→Jiuwen 已验证。A2A 复用现有代理,每调用独立客户端并传递可信身份,远端自行选择其 Core/模型。页面允许 Jiuwen 编辑协作绑定。 -- CM 复用现有 ContextManager,遵循保存开关与预算;配置页变更 Core 并按既有发布流程生效后,原会话可重开续聊,历史工具/附件作为只读事实,已有摘要仍可加载。仍未支持 Planning、运行总结、协议修复、校验/护栏及 provider prompt-cache 策略;公共 StepObservation/RunResult 已接入实时与历史展示,工具/结果、显式推理、子调用分组和失败/停止保留真实状态;终答仅由明确终态确认。保留绑定不表示已实现执行,不静默切回其他 Core。 -- 完整可信 `ResolvedModelProfile` 在管理侧保存,运行时恢复、验证并按请求预算生成独立快照;不按别名猜出口,不在运行时重新读 catalog,不走原生 HTTP。凭据只按 tenant/出口引用捕获,不进入模型资源视图。 +在部署共同使用的 `.env` 中配置: -M5 本地验证:515 项 Python UT、16 项前端组件、8 个正式 Case / 54 项检查通过。复用 SSE 与数据库执行单元,刷新后不按当前 Core 猜测旧输出;失败/停止保留过程,A2A 不虚构远端步骤。实际框架/资源/隔离数据库和受控 API 浏览器分别证明执行与展示,不等同于已部署的商业模型验收。证据见原 SPEC task.md 的 M5 历史记录;当时待实施的 AC-018 已在下述 M6 完成。 - -M6 已部署并通过选定真实出口:DeepSeek 原生工具协议、Qwen 受控文本协议,工具/MCP/Skills、两种子 Core 与 A2A 均完成实际执行桥验证。固定参数纠正每步骤 1 次/每轮 3 次(含已知 code 参数错误),空/仅推理/length 每轮 1 次;物理重试仍归 ModelService。原 Code 协议修复开关继续按 Jiuwen 能力校验。失败不切模型/Core,坏块不执行,历史任务不重放。 - -数据库在事务锁中分配用户/助手序号,空客户端历史也能从原会话续聊;私有运行边界不改变 HTTP schema。M6 862 项 Python UT、16 项前端组件和 10 个正式 Case / 72 项检查通过;真实模型与受控资源、浏览器受控 API 的边界详见原 SPEC task.md。应用访问 `http://localhost:3000`,镜像使用 `jiuwen-m6-20261016` 标签。全局正式资产门禁仍被既有 F-111-RULE-02 引用阻塞。 - -### 统一输出与原有会话页面(2026-10-16) - -不同Core输出在 `frontend/lib/nexentOutputAdapter.ts` 统一转换为原有Nexent事件,然后进入原有实时/历史解析器。页面使用同一套Reasoning、ToolFallback、Markdown和SubAgentContainer,不按Core选择渲染器;已移除M5的runtime-output专用路径。历史记录按自身保存的协议解析,智能体切换Core后仍可加载旧记录。 - -工具/MCP/Skills映射原工具卡,子智能体/A2A沿用原分组;只有封口后的成功终态生成终答,失败/停止保留partial。token用量绑定本条消息,未知显示未知,不借用其他轮计数。新Core需在执行端满足公共事实合同;其注册与配置校验仍独立适配。 - -本轮Web已部署 `nexent/nexent-web:jiuwen-unified-ui-20261016`,后端保持jiuwen-image-20261016。63项前端UT、TypeScript及定向lint通过,既有D3/D5回归和最终部署上的固定浏览器旅程通过;真实后端旧历史加载/刷新通过,未触发新Agent运行。标准prepared入口/全局资产门禁仍被既有F-111-RULE-02缺失引用阻塞,不声明完整发布门禁通过。完整证据见原SPEC新增UO记录。 - -### 共享容量展示补齐(2026-10-19) - -Jiuwen公共StepObservation.context_capacity现从已有ContextManager读取容量、阈值、有效输入上限和处理模式,经Observer及共用输出Adapter映射到原token_count。CM关闭仍保留容量,原用量百分比/详情组件无需Core分支。旧历史缺字段时保留未知,不用当前模型值回填。 - -main/Web已部署jiuwen-capacity-20261019。108项Python UT、43项前端UT及既有D3/D5回归通过;文件解析助手8轮真实业务验证覆盖两Core、CM开关和刷新,窗口/阈值与用量形式一致。正式D4仍因AIDP列表502为FAIL,prepared入口仍有F-111引用阻塞;详见原SPEC容量补齐记录,不宣称完整发布通过。 +```dotenv +NEXENT_AGENT_CORE=jiuwen +``` -### 升级与存量出口回填 +支持 `smolagents` 和 `jiuwen`,未配置时默认 `smolagents`;空值或未知值在后端启动时拒绝。配置仅由 `backend/contracts/constants/environment.py` 读取,再由应用层注入 SDK,SDK 不读环境变量。 -1. 核对现有 Core 字段迁移记录;已应用的 v2.7.2_1010 不重复执行,不改旧 SQL。 -2. 部署代码前应用新增 [模型快照迁移](../deploy/sql/migrations/v2.7.2_1015_runtime_outlet_snapshot.sql)。它只加过渡 JSONB 字段,保留旧模型 API/列。 -3. 在配置好后端数据库连接的部署环境使用 Python 3.11 执行 `backend/scripts/migrate_runtime_outlets.py --tenant-id `,默认 dry run;确认结果后加 `--apply` 写入。失败项不能作为可运行的 Jiuwen 出口;重复 apply 对未变化档案幂等。 -4. 后续模型管理保存、健康检查和租户默认模型写入会在同一事务维护快照。普通修改按冻结声明校验、行锁合并和递增 revision。旁路修改旧列使档案陈旧时,Jiuwen 提前拒绝,需要通过管理保存或迁移修复。 +变更后重启全部相关后端角色并刷新页面。Core 统一作用于普通会话、调试、工作台、本地主智能体和子智能体、NL2Agent 与 NL2Skills;远端 A2A 的内部 Core 仍由远端系统决定。模型出口、模型参数、工具和授权仍分别属于各智能体,不因统一选核而共用模型或扩大权限。 -M4 本地验证:753 项 Python UT、4 项前端组件、10 个正式 Case / 54 项测试通过;包含真实隔离 PostgreSQL 的双向配置 Core 变更、长历史摘要、CM 开关、故障/取消恢复和浏览器旧会话续聊。完整发布工作流及商业模型验证不在本轮证明范围;显式版本绑定不改写,未部署。证据见原 SPEC task.md 的 M4 记录。 +智能体配置页面不再显示 Core 字段或选择器。创建、更新、发布版本和导入导出不保存逐智能体 Core;旧客户端/导出包中的 `agent_core` 被忽略。上下文管理器开关、模型、资源绑定与业务提示继续保存。不兼容的非安全执行选项只在运行副本中归一,已启用的未支持护栏明确拒绝,不静默移除安全配置。 -上轮已在隔离 PostgreSQL 验证迁移、回填、并发和回滚。本轮完成 853 项 Python UT、4 项前端组件回归和 7 个正式 Case / 39 项检查,含混合 Core、子工具授权隔离、A2A、取消/超时/容量耗尽恢复与页面协作编辑。文本父子同时执行需要相应 sandbox worker 容量;协作排队和执行受有限总 deadline 控制。未修改现有应用数据库、未重建部署容器;具体证据与边界详见原 SPEC task.md。全局资产门禁仍被既有 F-111-RULE-02 引用阻塞;不声明全局通过或真实商业模型验收完成。 +本特性未合并的 `v2.7.2_1010_agent_core_factory.sql` 已删除,ORM 不再要求 `ag_tenant_agent_t.agent_core`。新环境无需为选核新增列;旧测试库若已有多余列,新代码直接忽略,不自动 DROP 或重写业务数据。此结论只针对 Core 选核,上游其他功能的数据库迁移仍需按版本执行。 ---- +SDK 独立调用仍支持显式注入 `AgentConfig(agent_core="jiuwen", ...)`,用于工厂及其他集成;系统内运行参数统一由后端部署策略生成。公共事件中的 `agent_core` 是历史运行来源标签,继续保留,不是智能体配置项。 -## 以下为首版历史说明(不作为当前启用操作) +## Jiuwen 执行边界 -## 本次实现 +- Jiuwen 负责 Agent loop;模型发送只走 Nexent 统一 ModelService 和可信 `ResolvedModelProfile`,不构造另一套提供商 HTTP 客户端。 +- 普通工具、MCP、Skills、知识库、子智能体和 A2A 通过公共授权资源桥执行。子智能体使用自己的模型出口、版本和授权;本地 Core 遵循同一全局策略。 +- Python 与文件生成使用 Nexent 受控执行器和配置的沙箱。Skills 是授权本地资源,不是待下载的 S3 文件;脚本只执行授权清单中真实存在的内容。 +- ContextManager 和数据库历史沿用 Nexent 的预算、开关、摘要及检查点;已有历史作为事实载入,不重放旧任务。变更部署 Core 后原会话仍按公共历史合同加载。 +- StepObservation、RunResult、工具/子调用、推理和容量经过公共输出协议,前端共用既有解析和渲染,不按 Core 增加另一套卡片。 -Nexent 继续保存智能体、模型、工具、MCP 和 Skills 配置。执行时,`AgentCoreFactory` 根据 `agent_core` 创建 Adapter,由 Adapter 编译配置并执行所选 Core。 +## 人在回路 -```text -Nexent 配置 → AgentCoreFactory → AgentCoreAdapter - ├─ SmolagentsAdapter → 现有执行链路 - └─ JiuwenAdapter → create_deep_agent → Runner.run_agent_streaming -``` +普通 Jiuwen 会话提供终止型 `ask_user` 能力。原生模式使用 JSON 工具调用,文本模式使用独立完整的字面量调用;两者共用 `ClarificationForm` 和缺少关键信息时的澄清策略。名称与业务工具冲突时使用避让名称。 -Jiuwen 路径使用原生模型客户端、LocalFunction、MCPTool 和 SkillUseRail。Nexent 原有工具的业务函数及沙箱设施继续复用;Jiuwen 负责模型循环、工具选择和调用。不会在该路径创建 smolagents Agent。 +完整响应校验通过后,后端发出一个既有 `human_interaction` 表单并以 `completed/awaiting_input` 结束本轮,不进入 Python 沙箱、不执行同批其他工具、不继续请求模型,也不把等待答案当作取消。用户提交走原有普通消息及历史恢复链路。普通文字回答不会被自动识别为表单;信息充分时模型应直接作答。NL2Agent/NL2Skills 继续使用各自的业务卡片/产物协议。 -## 如何启用 +## 验证与部署状态 -1. 先执行新增迁移 `deploy/sql/migrations/v2.6.0_1010_agent_core_factory.sql`。迁移为每个智能体版本添加 `agent_core`,存量数据默认 `smolagents`。 -2. 后端依赖固定为 `openjiuwen==0.1.18`;单独使用 SDK 时安装 `nexent[jiuwen]`。 -3. 通过现有 `POST /agent/update` 接口,在原请求中增加以下字段,并使用现有版本发布流程: +实现、传统 UT、受控 D3/D5、固定页面 D4、真实模型验收和已部署镜像分别记录,不用某一层通过替代其余层。最新进度及未完成范围见 [SPEC task](../../nexent-doc/docs/Developing/runtime-agent-core-factory/task.md)。2026-11-05 已重新构建并部署 `core-global-hitl-20261105` 后端/Web 镜像,当前全局 `jiuwen`。实际核对两种部署值均不显示逐智能体 Core 设置;算卦助手使用原 qwen3.8-max 出口的澄清卡片、提交续聊及已保存会话历史刷新验证通过。澄清提交在后端识别已保存的单层公共原生信封,仍使用既有表单身份和原子保存校验。工作台五模式与 NL2Agent/NL2Skills 完整真实业务旅程仍需单独验收。保存会话应从其历史入口(`thread_id`)打开;`agent_id` 是新会话入口,本轮没有修改该导航语义。 -```json -{ - "agent_core": "jiuwen", - "enable_context_manager": false, - "provide_run_summary": false, - "enable_planning": false, - "enable_protocol_repair_retry": false -} -``` -以上为字段片段;请求仍需携带原接口要求的智能体标识等字段。模型需要支持 OpenAI 兼容的工具调用协议。Skills 使用 Nexent 解析后的授权快照,MCP 使用 Nexent 解析后的服务器地址及认证配置。 +2026-11-06 后端已更新为 `jiuwen-loop-20261106`,Web沿用11-05版本。旧Agent思考参数在公共兼容边界按可信出口转换,保留原自定义字段优先级;会话低强度或关闭思考均可保存和运行,无需清空原智能体或历史配置。新格式参数与受保护字段仍严格校验。 -SDK 直接调用时设置 `AgentConfig(agent_core="jiuwen", ...)`。省略该字段或设置为 `smolagents`,使用原执行链路。当前没有新增前端选择控件。 +Jiuwen 的进程级 Runner 及回调框架由 `cores/jiuwen/execution_loop.py` 的稳定循环服务承载,归应用 `ThreadManager` 的 `background-service` 通道拥有,占一个专用服务名额。各主/子调用以独立协程并发运行、各自传递上下文与取消范围;服务自身在空 Context 中启动。单运行取消不会停止全局服务,应用退出排空任务后在原循环关闭 Runner;容量不足不另开非托管线程。阻塞 MCP 连接建立留在 Agent 工作线程。此修复避免跨事件循环竞争同一异步锁,不串行整个 Agent 执行,不修改前端渲染、模型或数据库合同。 -## 当前能力与边界 +最终镜像中原沙箱多子智能体与 deepseek-v4-flash 的两个并发会话各三轮通过:6次主调用、18次首次子调用全部成功,实时与历史一致,原配置及历史未改。SDK UT 466项通过,受控并发、取消隔离及 MCP/Skills 资源回归另行记录;完整工作区仍有3项既有无关资产门禁错误。具体 Case、准备清理及交付证据见 SPEC,不将这些结果等同于全部模型或浏览器业务验收。 -| 能力 | 当前实现 | -| --- | --- | -| 本地、内置及现有业务工具 | 业务函数绑定为原生 Jiuwen LocalFunction | -| MCP | 原生 Jiuwen MCPTool;只暴露已配置且可见的工具,保留认证、用户上下文注入和超时控制 | -| Skills | 原生 Rail 加载授权目录;读取与脚本通过原生工具调用,沿用 Nexent 脚本执行及沙箱后端 | -| 并行工具 | 原生工具调用及授权工具列表的 parallel_executor | -| 上下文、历史与流式结果 | 编译授权业务上下文和历史;转换为现有 MessageObserver 事件 | -| 停止与资源回收 | 每次运行独立 owner/session,取消及清理只作用于当前运行 | -| 保存、版本及导入导出 | 保留 agent_core;旧配置默认 smolagents | +2026-11-06 澄清历史读边界已修复,并按 `latest` 构建,经根 `deploy.sh --defaults docker --image-source local-latest --sandbox-mode full` 部署。正常 `completed/awaiting_input` 的空正文、空最终块、无错误结果表示等待用户输入,历史加载保留此终态;不将其当作缺少最终回答,也不改写数据库原记录。普通空回答、畸形协议、失败/停止和不符合上述形状的记录继续原校验。前端共用现有卡片和历史解析,无新增 Core 分支;具体回归和真实模型页面结果见 SPEC 最新记录。 -首版明确拒绝:子智能体、外部 A2A、Nexent Planning/Verification/Guardrail、协议修复、运行总结、自适应上下文压缩、模型提供方 Prompt Cache 策略及非默认 smolagents 输出协议。检查会返回配置错误;不会自动回退到 smolagents。关闭上下文压缩后仍执行已解析的输入预算检查。 +2026-11-09 Skill创建历史的业务事件边界已修复并重新部署latest。NL2Skill流在保存文件/摘要等业务事件前去除原生运行信封的判别字段,保留文件正文和追踪信息;可信Skill创建历史对旧文件包装做同样的只读投影,避免公共前端误报NEXENT_TERMINAL_MISSING或显示空文件。无需SQL或前端Core分支。121项UT、受控D3及真实工作台生成、保存、历史重开/刷新D4通过,截图旧记录3个文件逐项一致且原数据未改写;各Core真实模型验收范围见SPEC。 -Skills 自动增加的宿主机 bash/read_file 能力已关闭。脚本实际运行位置沿用智能体的 Nexent 沙箱配置。同步业务函数无法被 Python 强制终止时,沿用 ThreadManager 的取消和 STUCK 标记,不能保证强制终止该函数。 +2026-11-09 NL2Agent原生MCP交互边界已修复并部署latest。Jiuwen python_workflow识别MCPTool单层result文本并发出既有nl2a/nl2a_state;有效卡片阻止后续模型和工具派发,公共流排空拥有的工作线程与真实等待终态,普通取消保持原行为。卡片后已派发工具结果仍透传且边界保持;创建持久化和公共历史保留完整原生工具/结果身份。前端不增加Core分支。真实工作台完成卡片、提交续接及历史刷新,原配置/历史未改;详细验证和剩余门禁见SPEC。 -## 扩展其他 Core -继承 `AgentCoreAdapter`,实现 `capabilities()`、`validate()`、`run()`,按需实现 `prepare()` 和 `close()`,随后调用 `AgentCoreFactory.register("new_core", NewCoreAdapter)`。SDK 配置支持注册名;后端 API 当前仅开放 `smolagents` 和 `jiuwen`,接入新 Core 时同时扩展其配置校验。 +## NL2Agent结果与草稿入口 -## 验证结果 +工作台使用同一套公共实时/历史规则:只有可信且目标Agent ID一致的`agent_generation_completed`才生成结果跳转卡,消息内去重;后续普通回答不降级完成状态。澄清、空回答、字段保存和等待终态均不会产生结果卡,历史缺少完成事件时也不从正文推测成功,不改写原记录。 -- 新增正式 D1/D2/D3/D5 用例共 6 个,覆盖工厂、配置、事件、API 边界、原生执行、取消、MCP 超时和运行隔离。 -- D3 真实调用 Jiuwen Runner、MCP 客户端、本地工具和 Skill 脚本;仅模型响应及 MCP 服务由确定性本地服务提供。 -- Legacy 回归:SDK 执行 45 个、SDK 配置 69 个、数据库操作 67 个、后端配置构建 254 个测试通过。 -- 外部模型服务、远端沙箱和实际数据库迁移尚未执行,不能据此宣称生产环境验收完成。 +提前编辑从工作台工具栏的“编辑当前草稿”进入,绑定当前线程草稿目标;没有草稿或退出创建模式时隐藏,运行期间禁用且不提供可点击链接。打开草稿不会自动提交澄清或触发生成,用户返回原会话可以继续提交。 -固定测试位于 `test/automation/{d1,d2,d3,d5}`;用例契约和实现绑定位于 `test/cases`、`test/manifests/agent-core-factory.yaml`。 +2026-11-09主/Web latest已重新构建部署。smolagents和Jiuwen分别用真实模型完成澄清、提前编辑返回、提交生成、结果卡及历史重开/刷新;当前部署恢复为jiuwen,私有配置逐字节还原、原数据未变。定向前端96项、Python复用96项、8变体D3通过;两次提供商TLS失败保留为失败,最终同版本重跑通过。全量前端与全工作区资产的既有阻塞另见SPEC task,不能由这些定向结果推出全量通过。 diff --git a/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx b/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx index 914c826f9d..1f7880f142 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/advanced/GuardrailConfigContent.tsx @@ -1,5 +1,7 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; +import { useDeployment } from "@/components/providers/deploymentProvider"; import { useState, useMemo, useCallback, useEffect, useRef, forwardRef, useImperativeHandle } from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -172,9 +174,9 @@ function useGuardrailConfigState() { export function GuardrailConfigActions() { const { t } = useTranslation("common"); const { config, updateConfig } = useGuardrailConfigState(); - const disabled = useAgentStore( - (state) => state.isReadOnly || state.editedAgent?.agent_core === "jiuwen" - ); + const { agentCore } = useDeployment(); + const readOnly = useAgentStore((state) => state.isReadOnly); + const disabled = readOnly || (agentCore === "jiuwen" && !config.enabled); return ( state.editedAgent!); const updateAgent = useAgentStore((state) => state.updateAgentConfig); const isReadOnly = useAgentReadOnly(); - const coreUnsupported = editedAgent.agent_core === "jiuwen"; + const { agentCore } = useDeployment(); + const coreUnsupported = agentCore === "jiuwen"; return ( state.editedAgent!); const updateAgent = useAgentStore((state) => state.updateAgentConfig); const isReadOnly = useAgentReadOnly(); - const coreUnsupported = editedAgent.agent_core === "jiuwen"; + const { agentCore } = useDeployment(); + const coreUnsupported = agentCore === "jiuwen"; if (highFidelity) { return (
- +