Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 162 additions & 164 deletions backend/agents/create_agent_info.py

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions backend/apps/agent_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Query, Request, UploadFile
from fastapi.encoders import jsonable_encoder
from nexent.core.concurrency import run_blocking
from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError
from starlette.responses import JSONResponse, Response, StreamingResponse

from consts.const import ASSET_OWNER_TENANT_ID, ENABLE_AGENT_WORKBENCH
Expand Down Expand Up @@ -356,6 +357,13 @@ async def nl2agent_run_api(
_, tenant_id, language = get_current_user_info(
authorization, http_request
)
if nl2agent_request.conversation_id is not None:
from services.conversation_model_preferences_service import read_model_preferences
preference_user, _ = get_current_user_id(authorization)
saved = await run_blocking("creator-model-preferences", read_model_preferences, nl2agent_request.conversation_id, preference_user)
nl2agent_request.__dict__["_runtime_model_parameter_preferences"] = saved["model_parameter_preferences"]
if nl2agent_request.model_id is None:
nl2agent_request.model_id = saved.get("selected_model_id")
stream = await create_nl2agent_stream(
request=nl2agent_request,
tenant_id=tenant_id,
Expand All @@ -370,6 +378,8 @@ async def nl2agent_run_api(
query=nl2agent_request.query,
minio_files=nl2agent_request.minio_files,
workbench_config=nl2agent_request.workbench_config,
model_parameter_preferences=nl2agent_request.model_parameter_preferences,
selected_model_id=nl2agent_request.model_id,
agent_id=nl2agent_request.agent_id,
user_id=user_id,
tenant_id=tenant_id,
Expand Down Expand Up @@ -547,6 +557,9 @@ async def update_agent_info_api(
user_id=user_id, tenant_id=tenant_id,
details=audit_details)
return result or {}
except ParameterValidationError as exc:
raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
detail={"code": exc.category, "field": exc.path}) from None
except AgentRelationValidationError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
Expand Down
76 changes: 76 additions & 0 deletions backend/apps/conversation_management_app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

import logging
from http import HTTPStatus
from typing import Annotated, Any, Dict, Optional
Expand All @@ -8,6 +9,8 @@

from consts.model import (
BatchDeleteConversationRequest,
ConversationModelPreferencePatch,
ConversationModelSelectionPatch,
ConversationKnowledgeScopeUpdateRequest,
WorkbenchConfigUpdateRequest,
ConversationRequest,
Expand All @@ -19,6 +22,8 @@
)
from consts.exceptions import (
AppException,
ModelOutletConflictError,
ModelOutletValidationError,
ConversationNotFoundError,
ValidationError,
TokenExpiredError,
Expand Down Expand Up @@ -297,6 +302,77 @@ async def update_conversation_workbench_config_endpoint(
raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "WORKBENCH_CONFIG_INVALID"}) from exc


@router.get("/{conversation_id}/model_preferences", response_model=ConversationResponse)
async def get_conversation_model_preferences(conversation_id: int, authorization: Optional[str] = Header(None)):
from nexent.core.concurrency import run_blocking
from services.conversation_model_preferences_service import read_model_preferences

user_id, _ = get_current_user_id(authorization)
try:
result = await run_blocking("conversation-model-preferences-read", read_model_preferences, conversation_id, user_id)
return ConversationResponse(code=0, message="success", data=result)
except ConversationNotFoundError:
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None


@router.patch("/{conversation_id}/model_preferences", response_model=ConversationResponse)
async def patch_conversation_model_selection(conversation_id: int, request: ConversationModelSelectionPatch,
authorization: Optional[str] = Header(None)):
from nexent.core.concurrency import run_blocking
from services.conversation_model_preferences_service import patch_model_selection
from consts.exceptions import ForbiddenError, RunModelControlsError

user_id, tenant_id = get_current_user_id(authorization)
try:
result = await run_blocking("conversation-model-selection-update", patch_model_selection,
conversation_id, request, user_id, tenant_id)
return ConversationResponse(code=0, message="success", data=result)
except ConversationNotFoundError:
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None
except ModelOutletConflictError:
raise HTTPException(status_code=409, detail={"code": "MODEL_PREFERENCE_VERSION_CONFLICT"}) from None
except ForbiddenError:
raise HTTPException(status_code=403, detail="MODEL_SELECTION_FORBIDDEN") from None
except RunModelControlsError as error:
status = {"model_not_available": 404, "model_unavailable": 409, "model_type_invalid": 400}.get(error.code, 422)
raise HTTPException(status_code=status, detail={"code": error.code}) from None


@router.get("/{conversation_id}/model_preferences/{model_id}", response_model=ConversationResponse)
async def get_conversation_model_preference_view(conversation_id: int, model_id: int, authorization: Optional[str] = Header(None)):
from nexent.core.concurrency import run_blocking
from services.conversation_model_preferences_service import read_model_preference_view

user_id, tenant_id = get_current_user_id(authorization)
try:
result = await run_blocking("conversation-model-preference-view", read_model_preference_view,
conversation_id, model_id, user_id, tenant_id)
return ConversationResponse(code=0, message="success", data=result)
except ConversationNotFoundError:
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None
except ModelOutletValidationError as error:
raise HTTPException(status_code=422, detail={"code": error.category, "field": error.path}) from None


@router.patch("/{conversation_id}/model_preferences/{model_id}", response_model=ConversationResponse)
async def patch_conversation_model_preferences(conversation_id: int, model_id: int,
request: ConversationModelPreferencePatch, authorization: Optional[str] = Header(None)):
from nexent.core.concurrency import run_blocking
from services.conversation_model_preferences_service import patch_model_preferences

user_id, tenant_id = get_current_user_id(authorization)
try:
result = await run_blocking("conversation-model-preferences-update", patch_model_preferences,
conversation_id, model_id, request, user_id, tenant_id)
return ConversationResponse(code=0, message="success", data=result)
except ConversationNotFoundError:
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None
except ModelOutletConflictError:
raise HTTPException(status_code=409, detail={"code": "MODEL_PREFERENCE_VERSION_CONFLICT"}) from None
except ModelOutletValidationError as error:
raise HTTPException(status_code=422, detail={"code": error.category, "field": error.path}) from None


@router.post("/sources", response_model=Dict[str, Any])
async def get_sources_endpoint(request: Dict[str, Any], authorization: Optional[str] = Header(None)):
"""
Expand Down
10 changes: 10 additions & 0 deletions backend/apps/skill_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Request, UploadFile
from nexent.core.concurrency import run_blocking
from pydantic import BaseModel, Field
from starlette.responses import JSONResponse, StreamingResponse

Expand Down Expand Up @@ -814,6 +815,13 @@ async def nl2skill_run_api(
raise HTTPException(status_code=401, detail="Unauthorized")

try:
if request.conversation_id is not None:
from services.conversation_model_preferences_service import read_model_preferences
preference_user, _ = get_current_user_id(authorization)
saved = await run_blocking("creator-model-preferences", read_model_preferences, request.conversation_id, preference_user)
request.__dict__["_runtime_model_parameter_preferences"] = saved["model_parameter_preferences"]
if request.model_id is None:
request.model_id = saved.get("selected_model_id")
stream = await create_nl2skill_stream(
request=request,
tenant_id=tenant_id,
Expand All @@ -827,6 +835,8 @@ async def nl2skill_run_api(
query=request.query,
minio_files=request.minio_files,
workbench_config=request.workbench_config,
model_parameter_preferences=request.model_parameter_preferences,
selected_model_id=request.model_id,
agent_id=None,
user_id=user_id,
tenant_id=tenant_id,
Expand Down
9 changes: 0 additions & 9 deletions backend/configs/model_reasoning_wire_profiles.example.json

This file was deleted.

5 changes: 0 additions & 5 deletions backend/consts/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,11 +947,6 @@ def _resolve_app_version(default: str = "v2.2.1") -> str:
NEXENT_LITELLM_JSON = os.environ.get("NEXENT_LITELLM_JSON", "/opt/nexent/litellm_models.json")


# Exact, operator-verified endpoint/model reasoning transport overrides.
MODEL_REASONING_WIRE_PROFILES_PATH = os.getenv(
"MODEL_REASONING_WIRE_PROFILES_PATH",
os.path.join(os.path.dirname(__file__), "..", "configs", "model_reasoning_wire_profiles.json"),
)
MODEL_RESPONSE_DIAGNOSTIC_CHUNKS = os.getenv(
"NEXENT_MODEL_DIAGNOSTIC_CHUNKS", os.getenv("NEXENT_PROTOCOL_DIAGNOSTIC_CONTENT", "0")
) == "1"
115 changes: 19 additions & 96 deletions backend/consts/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,30 +1055,14 @@ class RuntimeSkillMount(BaseModel):
config_values: Dict[str, Any] = Field(default_factory=dict)


class WorkbenchGenerationConfig(BaseModel):
"""Provider-neutral generation settings persisted with the conversation."""

model_config = ConfigDict(extra="forbid")

deep_thinking: bool = False
thinking_effort: Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"] = "low"
reasoning_budget_tokens: Optional[StrictInt] = Field(default=None, ge=0)
temperature: Optional[float] = Field(default=None, ge=0, le=2, strict=True, allow_inf_nan=False)
top_p: Optional[float] = Field(default=None, ge=0, le=1, strict=True, allow_inf_nan=False)
requested_output_tokens: Optional[int] = Field(default=None, gt=0)


class WorkbenchSessionConfig(BaseModel):
"""Canonical, persisted Workbench declaration; resolved artifacts are excluded."""

model_config = ConfigDict(extra="forbid")

schema_version: Literal[3] = 3
schema_version: Literal[4] = 4
mode: WorkbenchMode
model_id: Optional[int] = Field(default=None, gt=0)
generation_config: WorkbenchGenerationConfig = Field(
default_factory=WorkbenchGenerationConfig
)
requested_output_tokens: Optional[int] = Field(default=None, gt=0)
agent_mounts: List[RuntimeAgentMount] = Field(default_factory=list, max_length=8)
skill_mounts: List[RuntimeSkillMount] = Field(default_factory=list, max_length=20)
knowledge_scope: Optional[ConversationKnowledgeScopeRequest] = None
Expand Down Expand Up @@ -1129,30 +1113,25 @@ def reject_legacy_agent_fields(value):
return value


def reject_temporary_model_controls(value):
"""Reject the removed parallel contract instead of silently dropping intent."""
if isinstance(value, dict) and {"generation_config", "reasoning_effort", "reasoning_budget_tokens"}.intersection(value):
raise ValueError("Use model_parameter_preferences for conversation model parameters")
if (isinstance(value, dict) and value.get("conversation_id") is not None
and value.get("model_parameter_preferences") and value.get("is_debug") is not True):
raise ValueError("Save existing conversation parameters through the preference endpoint")
return value


class AgentRequest(BaseModel):
query: str
conversation_id: Optional[int] = None
history: Optional[List[HistoryItem]] = None
# Complete list of attachment information
minio_files: Optional[List[Dict[str, Any]]] = None
agent_id: Optional[int] = None
model_id: Optional[int] = None
generation_config: Optional[WorkbenchGenerationConfig] = None
reasoning_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = Field(
default=None,
description=(
"Optional per-run reasoning effort. None inherits the selected model "
"or Agent default."
),
)
reasoning_budget_tokens: Optional[int] = Field(
default=None,
gt=0,
description=(
"Optional per-run reasoning token budget. None inherits the selected "
"model or Agent default."
),
)
model_id: Optional[StrictInt] = Field(default=None, gt=0)
model_parameter_preferences: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
requested_output_tokens: Optional[int] = Field(default=None, gt=0)
version_no: Optional[int] = None
is_debug: Optional[bool] = False
Expand All @@ -1178,7 +1157,7 @@ class AgentRequest(BaseModel):
@model_validator(mode="before")
@classmethod
def reject_legacy_execution_controls(cls, value):
return reject_legacy_agent_fields(value)
return reject_temporary_model_controls(reject_legacy_agent_fields(value))

@field_validator("context_policy")
@classmethod
Expand All @@ -1203,72 +1182,16 @@ def validate_workbench_entrypoint(self):
)


class RuntimeGenerationOverrides(BaseModel):
"""Partial generation intent; absent fields never materialize defaults."""

model_config = ConfigDict(extra="forbid")
deep_thinking: Optional[StrictBool] = None
thinking_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = None
reasoning_budget_tokens: Optional[StrictInt] = Field(default=None, ge=0)
temperature: Optional[float] = Field(default=None, ge=0, le=2, strict=True, allow_inf_nan=False)
top_p: Optional[float] = Field(default=None, ge=0, le=1, strict=True, allow_inf_nan=False)

@field_validator("deep_thinking", "thinking_effort", "reasoning_budget_tokens", mode="before")
@classmethod
def reject_explicit_null(cls, value):
if value is None:
raise ValueError("A reasoning override cannot be null; omit it to inherit")
return value


class NL2ModelControlsRequest(BaseModel):
"""Shared optional controls for direct and Workbench creator turns."""
"""The same per-model preferences used by every Agent conversation."""

model_id: Optional[StrictInt] = Field(default=None, gt=0)
generation_config: Optional[RuntimeGenerationOverrides] = None
reasoning_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = None
reasoning_budget_tokens: Optional[StrictInt] = Field(default=None, ge=0)
model_parameter_preferences: Dict[str, Dict[str, Any]] = Field(default_factory=dict)

@model_validator(mode="before")
@classmethod
def reconcile_model_controls(cls, value):
if not isinstance(value, dict):
return value
value = dict(value)
overlay = value.get("generation_config")
if isinstance(overlay, BaseModel):
overlay = overlay.model_dump(exclude_unset=True)
if overlay is not None and not isinstance(overlay, dict):
return value
overlay = dict(overlay or {})
snapshot = value.get("workbench_config")
if isinstance(snapshot, dict):
snapshot_model = snapshot.get("model_id")
if snapshot_model is not None:
if value.get("model_id") is not None and value["model_id"] != snapshot_model:
raise ValueError("model_controls_invalid: conflicting model snapshot")
value["model_id"] = snapshot_model
saved = snapshot.get("generation_config")
if isinstance(saved, dict):
for key in ("deep_thinking", "thinking_effort", "reasoning_budget_tokens", "temperature", "top_p"):
if key in ("temperature", "top_p") and key in saved and saved[key] is None:
if overlay.get(key) is not None:
raise ValueError("model_controls_invalid: conflicting sampling snapshot")
continue
if key not in saved or saved[key] is None:
continue
if key in overlay and overlay[key] != saved[key]:
raise ValueError("model_controls_invalid: conflicting generation snapshot")
overlay[key] = saved[key]
for legacy, canonical in (("reasoning_effort", "thinking_effort"),
("reasoning_budget_tokens", "reasoning_budget_tokens")):
if value.get(legacy) is not None:
if canonical in overlay and overlay[canonical] != value[legacy]:
raise ValueError("model_controls_invalid: conflicting reasoning override")
overlay[canonical] = value[legacy]
if overlay or value.get("generation_config") is not None:
value["generation_config"] = overlay
return value
def reject_removed_controls(cls, value):
return reject_temporary_model_controls(value)


class NL2AgentRunRequest(NL2ModelControlsRequest):
Expand Down
Loading