Skip to content
Open
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
89 changes: 89 additions & 0 deletions backend/agents/create_agent_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -2078,6 +2078,94 @@ def check_agent_tools(agent_config: AgentConfig):
return list(used_mcp_urls)


def _as_config_list(value: Any) -> List[Any]:
"""Coerce an agent-config collection attribute to a real list defensively.

Guards against non-iterable stand-ins (e.g. mocks) so the user-context
condition check never raises on unusual config objects.
"""
if isinstance(value, (list, tuple)):
return list(value)
return []


def _agent_tree_needs_user_context(agent_config: AgentConfig) -> bool:
"""Whether the agent tree may need caller user context for tool-side authorization.

True when the agent (or any sub-agent) uses MCP tools or external A2A
agents; pure local/builtin tool trees skip the user-context DB lookups.
"""
tools = _as_config_list(getattr(agent_config, "tools", None))
if any(getattr(tool, "source", None) == "mcp" for tool in tools):
return True
if _as_config_list(getattr(agent_config, "external_a2a_agents", None)):
return True
return any(
_agent_tree_needs_user_context(sub_agent)
for sub_agent in _as_config_list(getattr(agent_config, "managed_agents", None))
)


def _build_tool_user_context(user_id: str, tenant_id: str) -> Dict[str, Any]:
"""Build the caller user context passed through to tools for tool-side authorization.

The platform itself does no authorization here; it only assembles the
authenticated-session identity (tenant name, user name/account, groups) so
tools can authorize on their own before accessing data. Any lookup failure
degrades to a minimal context instead of blocking the conversation.
"""
from consts.const import TENANT_NAME
from database.group_db import query_groups_by_user
from database.tenant_config_db import get_single_config_info
from database.user_tenant_db import get_user_tenant_by_user_id

user_context: Dict[str, Any] = {
"tenant_id": str(tenant_id or ""),
"tenant_name": str(tenant_id or ""),
"user_id": str(user_id or ""),
"user_name": "",
"user_account": "",
"user_groups": [],
}
try:
name_record = get_single_config_info(tenant_id, TENANT_NAME)
tenant_name = (name_record or {}).get("config_value")
if tenant_name:
user_context["tenant_name"] = str(tenant_name)
except Exception as exc:
logger.warning("tool user context: tenant name lookup failed: %s", exc)
try:
user_tenant = get_user_tenant_by_user_id(user_id)
user_email = (user_tenant or {}).get("user_email") or ""
user_context["user_name"] = user_email
user_context["user_account"] = user_email
except Exception as exc:
logger.warning("tool user context: user email lookup failed: %s", exc)
try:
groups = query_groups_by_user(user_id) or []
user_context["user_groups"] = [
str(g.get("group_name")) for g in groups if g.get("group_name")
]
except Exception as exc:
logger.warning("tool user context: user groups lookup failed: %s", exc)
return user_context


def _resolve_tool_user_context(agent_config: AgentConfig, user_id: str, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Resolve the caller user context, degrading to None on any failure.

Building the context must never block conversation execution, so every
error (including unusual agent config shapes) falls back to no context.
"""
try:
if not _agent_tree_needs_user_context(agent_config):
return None
return _build_tool_user_context(user_id, tenant_id)
except Exception as exc:
logger.warning("tool user context: build skipped: %s", exc)
return None


async def create_agent_run_info(
agent_id,
minio_files,
Expand Down Expand Up @@ -2256,5 +2344,6 @@ async def create_agent_run_info(
tenant_id=tenant_id,
minio_files=minio_files,
redis_client=get_redis_client(),
user_context=_resolve_tool_user_context(agent_config, user_id, tenant_id),
)
return agent_run_info
36 changes: 36 additions & 0 deletions doc/docs/en/user-guide/agent-development/agent-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,42 @@ You can also develop your own MCP services and connect them to Nexent; see [MCP
> - Convert third-party service HTTP APIs into MCP tools
> - Generate tools directly from OpenAPI specifications without writing MCP Server code

### 🔐 Pass User Information to Tools (Tool-side Authorization)

When an agent invokes MCP tools, collaborative agents, or external A2A agents, the platform passes the **current caller's user information** according to each tool's declaration, so the tool can authorize on its own before accessing data.

🔔 **Platform boundary**: the platform itself performs no authorization for tools; it only passes through the authenticated session identity. Authorization is the tool's responsibility.

**How to declare**: if a tool's input schema defines any of the conventional field names below, the platform treats it as requesting that user information and fills the field with the current user's value at call time:

| Conventional field | Meaning |
|--------------------|---------|
| `tenant_id` | Tenant ID |
| `tenant_name` | Tenant name |
| `user_id` | User ID |
| `user_name` | User name |
| `user_account` | User account (email) |
| `user_groups` | List of user-group names the user belongs to |

**Example**: a data query tool that enforces data permissions by caller account and groups only needs to declare `user_account` and `user_groups` in its inputSchema:

```json
{
"type": "object",
"properties": {
"query": { "type": "string", "description": "Query content" },
"user_account": { "type": "string", "description": "Caller account (injected by the platform)" },
"user_groups": { "type": "array", "items": { "type": "string" }, "description": "Caller user groups (injected by the platform)" }
}
}
```

> 💡 **Notes**:
>
> - These conventional fields are **invisible to the model**: the model neither sees nor fills them, and injected values come only from the current authenticated session, so they cannot be forged
> - Undeclared conventional fields are never injected and do not affect the tool's existing parameters
> - When an agent calls collaborative agents (including external A2A agents), the user information is passed through in the request metadata

### ⚙️ Custom Tools

You can refer to the following guides to develop your own tools and integrate them into Nexent to enrich agent capabilities:
Expand Down
36 changes: 36 additions & 0 deletions doc/docs/zh/user-guide/agent-development/agent-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,42 @@ Nexent 支持通过 A2A 协议与第三方 Agent 进行通信。您可以通过
> - 将第三方服务的 HTTP API 转换为 MCP 工具
> - 无需编写 MCP Server 代码,直接通过 OpenAPI 规范生成工具

### 🔐 向工具透传用户信息(工具侧鉴权)

智能体调用 MCP 工具、协同 Agent 或外部 A2A Agent 时,平台会**根据工具的声明**传入当前调用者的用户信息,供工具在访问数据前自行鉴权。

🔔 **平台边界**:平台本身不对工具侧做鉴权,只透传认证会话中的用户身份;鉴权由工具自行完成。

**声明方式**:工具的输入参数 Schema 中定义了以下任意约定字段名,即视为需要该用户信息,平台会在调用时自动以当前用户的值填充:

| 约定字段名 | 含义 |
|-----------|------|
| `tenant_id` | 租户 ID |
| `tenant_name` | 租户名 |
| `user_id` | 用户 ID |
| `user_name` | 用户名 |
| `user_account` | 用户账号(邮箱) |
| `user_groups` | 用户所属用户组名列表 |

**示例**:某数据查询工具需要按调用者账号和用户组做数据权限控制,在其 inputSchema 中声明 `user_account` 与 `user_groups` 两个参数即可:

```json
{
"type": "object",
"properties": {
"query": { "type": "string", "description": "查询内容" },
"user_account": { "type": "string", "description": "调用者账号(平台自动注入)" },
"user_groups": { "type": "array", "items": { "type": "string" }, "description": "调用者所属用户组(平台自动注入)" }
}
}
```

> 💡 **说明**:
>
> - 这些约定字段对**模型不可见**:模型不知道它们的存在、不会为其填值,注入值只来自当前登录会话,无法被伪造
> - 未声明的约定字段不会注入,不影响工具的既有参数
> - 智能体调用协同 Agent(含外部 A2A Agent)时,用户信息随请求的 metadata 透传

### ⚙️ 自定义工具

您可参考以下指导文档,开发自己的工具,并接入 Nexent 使用,丰富智能体能力。
Expand Down
12 changes: 10 additions & 2 deletions sdk/nexent/core/agents/a2a_agent_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,14 +726,18 @@ def __init__(
self,
agent_info: A2AAgentInfo,
stop_event: Optional[Event] = None,
observer: Optional[Any] = None
observer: Optional[Any] = None,
user_context: Optional[Dict[str, Any]] = None
):
"""Initialize the external A2A agent wrapper.

Args:
agent_info: Configuration for the external A2A agent.
stop_event: Optional stop event for cancellation.
observer: Optional message observer for logging.
user_context: Optional caller user context (tenant/user/groups)
forwarded to the external agent via message metadata so it
can authorize before accessing data.
"""
self.name = agent_info.name
# Use skills description if available
Expand All @@ -743,6 +747,7 @@ def __init__(
self.observer = observer
self._proxy: Optional[ExternalA2AAgentProxy] = None
self._runtime_metadata: Dict[str, Any] = {}
self._user_context: Dict[str, Any] = deepcopy(user_context or {})
# Required by smolagents for managed agents
self.inputs = {
"task": {"type": "string", "description": "Task description for the external agent."},
Expand Down Expand Up @@ -790,10 +795,13 @@ def __call__(self, task: str = None, **kwargs) -> str:
return "Error: No task provided"

try:
context = self.get_runtime_metadata()
if self._user_context:
context["user_context"] = deepcopy(self._user_context)
result = self._proxy.sync_call(
query,
history,
context=self.get_runtime_metadata(),
context=context,
)
return result
except Exception as e:
Expand Down
6 changes: 6 additions & 0 deletions sdk/nexent/core/agents/agent_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,12 @@ class AgentRunInfo(BaseModel):
stop_event: Event = Field(description="Stop event control")
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,
)
runtime_metadata: Dict[str, Any] = Field(
description="Immutable application-resolved runtime metadata snapshot",
default_factory=dict,
Expand Down
12 changes: 9 additions & 3 deletions sdk/nexent/core/agents/nexent_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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 .tool_user_context import apply_user_context_to_mcp_tool
from .agent_model import AgentConfig, AgentHistory, ModelConfig, ToolConfig
from .core_agent import CoreAgent, convert_code_format

Expand Down Expand Up @@ -206,19 +207,20 @@


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):
minio_files=None,
user_context=None):

Check warning on line 223 in sdk/nexent/core/agents/nexent_agent.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method "__init__" has 14 parameters, which is greater than the 13 authorized.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBF0Hw62u7JTGRXycHE&open=AaBF0Hw62u7JTGRXycHE&pullRequest=3798
"""
Initialize the NexentAgent factory.

Expand All @@ -238,6 +240,8 @@
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.
user_context: Optional caller user context (tenant/user/groups)
passed through to tools for tool-side authorization.
"""
if not isinstance(observer, MessageObserver):
raise TypeError("Create Observer Object with MessageObserver")
Expand All @@ -255,6 +259,7 @@
self.workspace_path = 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] = []
Expand Down Expand Up @@ -493,7 +498,7 @@
)
if tool_obj is None:
raise ValueError(f"{class_name} not found in MCP server")
return tool_obj
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.
Expand Down Expand Up @@ -732,7 +737,8 @@
wrapper = ExternalA2AAgentWrapper(
agent_info=a2a_agent_info,
stop_event=self.stop_event,
observer=self.observer
observer=self.observer,
user_context=self.user_context,
)
managed_agents_list.append(
self._wrap_subagent(
Expand Down
2 changes: 2 additions & 0 deletions sdk/nexent/core/agents/run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ def agent_run_thread(agent_run_info: AgentRunInfo):
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=agent_run_info.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,
Expand Down Expand Up @@ -249,6 +250,7 @@ def agent_run_thread(agent_run_info: AgentRunInfo):
stop_event=agent_run_info.stop_event,
mcp_tool_collection=tool_collection,
redis_client=agent_run_info.redis_client,
user_context=agent_run_info.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,
Expand Down
72 changes: 72 additions & 0 deletions sdk/nexent/core/agents/tool_user_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""User-context pass-through for agent tools.

The platform itself performs no authorization for tool calls. When an MCP tool's
input schema declares any of the conventional ``USER_CONTEXT_FIELDS``, the
platform injects the authenticated-session identity (tenant name, user
name/account, groups) right before execution so the tool can authorize on its
own before accessing data.

The conventional fields are hidden from the model-visible schema: the model
neither sees nor fills them, so injected values can only come from the
authenticated session.
"""
import functools
import inspect
from typing import Any, Dict, Optional

# Conventional user-context parameter names. Declaring one of these in an MCP
# tool's inputSchema means "this tool requests that user information".
USER_CONTEXT_FIELDS = (
"tenant_id",
"tenant_name",
"user_id",
"user_name",
"user_account",
"user_groups",
)


def apply_user_context_to_mcp_tool(tool_obj: Any, user_context: Optional[Dict[str, Any]]) -> Any:
"""Hide conventional user-context fields from the model and inject them at call time.

Tools whose input schema declares any of ``USER_CONTEXT_FIELDS`` receive the
session-resolved values injected right before ``forward``. Declared fields are
removed from ``tool.inputs`` so the model never sees or fills them; injected
values therefore come only from the authenticated session.

Args:
tool_obj: A smolagents-compatible tool object with ``inputs`` and ``forward``.
user_context: Session-resolved caller identity mapping.

Returns:
The (possibly wrapped) tool object. Tools declaring no conventional
fields, or runs without a user context, are returned unchanged.
"""
if not user_context or getattr(tool_obj, "_nexent_user_context_wrapped", False):
return tool_obj
inputs = getattr(tool_obj, "inputs", None)
if not isinstance(inputs, dict):
return tool_obj
declared = [field for field in USER_CONTEXT_FIELDS if field in inputs]
if not declared:
return tool_obj

# Hide the conventional fields from the model-visible schema.
tool_obj.inputs = {k: v for k, v in inputs.items() if k not in USER_CONTEXT_FIELDS}
injected = {field: user_context.get(field) for field in declared}
original_forward = tool_obj.forward

if inspect.iscoroutinefunction(original_forward):
@functools.wraps(original_forward)
async def forward_with_user_context(*args, **kwargs):
kwargs.update(injected)
return await original_forward(*args, **kwargs)
else:
@functools.wraps(original_forward)
def forward_with_user_context(*args, **kwargs):
kwargs.update(injected)
return original_forward(*args, **kwargs)

tool_obj.forward = forward_with_user_context
setattr(tool_obj, "_nexent_user_context_wrapped", True)
return tool_obj
Loading
Loading