From 19a98913cfaf78c5092c774f05929ff034867e65 Mon Sep 17 00:00:00 2001 From: Eddiward_yukika <145945731+EDDIWARD@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:39:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Feature:=20Pass=20user=20contex?= =?UTF-8?q?t=20to=20MCP=20tools=20and=20A2A=20agents=20for=20tool-side=20a?= =?UTF-8?q?uthorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/agents/create_agent_info.py | 89 +++++++++ .../agent-development/agent-configuration.md | 36 ++++ .../agent-development/agent-configuration.md | 36 ++++ doc/tool-user-context-design.md | 175 ++++++++++++++++++ sdk/nexent/core/agents/a2a_agent_proxy.py | 12 +- sdk/nexent/core/agents/agent_model.py | 6 + sdk/nexent/core/agents/nexent_agent.py | 12 +- sdk/nexent/core/agents/run_agent.py | 2 + sdk/nexent/core/agents/tool_user_context.py | 72 +++++++ test/backend/agents/test_create_agent_info.py | 115 ++++++++++++ .../sdk/core/agents/test_tool_user_context.py | 167 +++++++++++++++++ 11 files changed, 717 insertions(+), 5 deletions(-) create mode 100644 doc/tool-user-context-design.md create mode 100644 sdk/nexent/core/agents/tool_user_context.py create mode 100644 test/sdk/core/agents/test_tool_user_context.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 0ea6865be6..e9259a0f13 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -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, @@ -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 diff --git a/doc/docs/en/user-guide/agent-development/agent-configuration.md b/doc/docs/en/user-guide/agent-development/agent-configuration.md index e7884a489b..40bd338f61 100644 --- a/doc/docs/en/user-guide/agent-development/agent-configuration.md +++ b/doc/docs/en/user-guide/agent-development/agent-configuration.md @@ -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: diff --git a/doc/docs/zh/user-guide/agent-development/agent-configuration.md b/doc/docs/zh/user-guide/agent-development/agent-configuration.md index 9ca6fcda05..235bb46756 100644 --- a/doc/docs/zh/user-guide/agent-development/agent-configuration.md +++ b/doc/docs/zh/user-guide/agent-development/agent-configuration.md @@ -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 使用,丰富智能体能力。 diff --git a/doc/tool-user-context-design.md b/doc/tool-user-context-design.md new file mode 100644 index 0000000000..0bd66e20c4 --- /dev/null +++ b/doc/tool-user-context-design.md @@ -0,0 +1,175 @@ +# 工具用户信息透传 — 设计文档 + +> 分支:`edward/feature-tool-user-context`(worktree:`C:\Users\Edward\work\nexent-tool-user-context`) +> 基线:`origin/develop`(8f20f87cf,v2.5.0+) + +## 1. 需求 + +智能体调用 MCP 工具和 Agent(子智能体 / 外部 A2A)时,**根据工具要求**传入调用者的用户信息: + +| 字段 | 含义 | +|------|------| +| 租户名(tenant_name) | 租户显示名,如 `bug-repro` | +| 用户名(user_name) | 用户显示名(当前系统中即邮箱) | +| 用户账号(user_account) | 用户邮箱,如 `bug-admin@qq.com` | +| 用户组(user_groups) | 用户所属组名列表,如 `["Default Group"]` | + +工具用这些信息在**访问数据前自行鉴权**。 + +**边界**:平台本身不做鉴权、不校验工具侧权限,只负责把**经过平台认证的会话身份**如实透传给工具。 + +## 2. 现状调研结论 + +### 2.1 用户信息的数据来源(已全部确认) + +| 字段 | 存储位置 | 获取方式 | +|------|----------|----------| +| tenant_id | 会话上下文(JWT / access_key 解析) | 执行入口已有 | +| tenant_name | `tenant_config_t`(`config_key='TENANT_NAME'`) | `get_single_config_info(tenant_id, TENANT_NAME)`,缺失时回退 `tenant_id` | +| user_id | 会话上下文 | 执行入口已有 | +| user_name / user_account | `user_tenant_t.user_email` | `get_user_tenant_by_user_id(user_id)` | +| user_groups | `tenant_group_info_t` join `tenant_group_user_t` | `query_groups_by_user(user_id)` -> `group_name` 列表 | + +### 2.2 运行时工具调用链路 + +``` +run_agent_stream(user_id, tenant_id) [backend agent_service] + -> prepare_agent_run -> create_agent_run_info [backend create_agent_info] + -> AgentRunInfo -> agent_run_thread [sdk run_agent] + -> NexentAgent(user_id, tenant_id) [sdk nexent_agent] + |-- MCP 工具: create_tool -> create_mcp_tool(smolagents MCPClientTool) + | -> 模型生成代码 -> MCPClientTool.forward(**kwargs) -> session.call_tool(name, kwargs) + |-- 内部子 Agent: SubAgentToolWrapper.__call__(task=...) + +-- 外部 A2A: ExternalA2AAgentWrapper.__call__(task=...) + -> _build_message_payload(query, context) -> payload["metadata"] +``` + +关键事实: + +1. **`NexentAgent.create_mcp_tool`(`sdk/nexent/core/agents/nexent_agent.py` L487-496)是 MCP 工具的唯一收口点**,当前不注入任何用户上下文(对比:本地工具已有注入 `tenant_id/user_id` 的先例)。 +2. `user_id/tenant_id` 已逐层传到 `NexentAgent`(L253-254),**取用户身份不难,难的是取全四个字段并送到工具执行处**。 +3. 外部 A2A 已有 `metadata` 透传通道(`runtime_metadata` -> `payload["metadata"]`)。 +4. 内部子 Agent 与父 Agent 使用相同的 `user_id/tenant_id` 递归创建(`create_agent_config` L981-1007),上下文天然一致。 + +## 3. 总体设计 + +### 3.1 架构:构建 -> 传递 -> 注入 + +``` +[构建] create_agent_run_info(执行入口,每个请求重建) + 查询 4 字段 -> ToolUserContext + | +[传递] AgentRunInfo.user_context -> NexentAgent.user_context + | +[注入] |-- MCP 工具:create_mcp_tool 包装层(约定参数名注入,对模型隐藏) + |-- 外部 A2A:ExternalA2AAgentWrapper 独立注入 message.metadata["user_context"] + +-- 内部子 Agent:同一 NexentAgent 实例递归创建,工具注入链路天然一致 +``` + +### 3.2 用户上下文数据结构 + +```python +class ToolUserContext(BaseModel): + """透传给工具的用户信息。平台不鉴权,工具在访问数据前自行鉴权。""" + tenant_id: str + tenant_name: str # TENANT_NAME 配置,缺失回退 tenant_id + user_id: str + user_name: str # = user_email + user_account: str # = user_email + user_groups: list[str] # 组名列表,如 ["Default Group"] +``` + +构建位置:`backend/agents/create_agent_info.py` 的 `create_agent_run_info`(此处 user_id/tenant_id 已就位)。 + +**条件构建(性能)**:仅当智能体树包含 MCP 工具(任意层级 `source == "mcp"`)或外部 A2A Agent 时才执行构建(`_agent_tree_needs_user_context` 递归判断);纯本地/内置工具的对话**零额外 DB 查询**,高并发场景避免每请求 3 次无谓查询。 + +**防御性降级**:`_resolve_tool_user_context` 整体捕获异常返回 `None`;单个字段查询失败降级为最小上下文——**任何情况下都不阻断对话**。 + +### 3.3 注入通道一:MCP 工具(约定参数名,对模型隐藏) + +**核心原则:用户信息完全不进入模型视野——平台在模型传输外面包一层注入。** 模型不知道这些参数存在、不会为其生成值,也就无从伪造。 + +**两份 schema**: +- **模型可见 schema**:构建智能体工具列表时,从工具 `inputs` 中移除约定字段——模型看到的提示词/工具说明不含这些参数(不占 token、不引起困惑、无法填充); +- **实际调用 schema**:MCP 服务端的 `inputSchema` 保持不变(它即工具的"要求声明"),真正执行时由包装层把约定字段值补进参数。 + +**声明方式**:工具的 MCP 服务端 `inputSchema` 中定义了**约定字段名**,即视为"该工具要求此用户信息": + +| 约定参数名 | 注入值 | +|-----------|--------| +| `tenant_id` | tenant_id | +| `tenant_name` | 租户显示名 | +| `user_id` | user_id | +| `user_name` | user_email | +| `user_account` | user_email | +| `user_groups` | 组名列表(JSON 数组) | + +**注入点**:`NexentAgent.create_mcp_tool` 返回的工具对象包一层代理(属性转发可参考 `SubAgentToolWrapper` 的先例): + +```python +# 1) 构建工具列表时:从模型可见 schema 移除约定字段 +tool.inputs = {k: v for k, v in tool.inputs.items() if k not in USER_CONTEXT_FIELDS} + +# 2) 工具执行时:模型只生成业务参数,包装层在转发前补入约定值 +def __call__(self, **model_kwargs): + # model_kwargs 不含约定字段(模型不可见),原参数校验照常通过 + injected = dict(model_kwargs) + for field in USER_CONTEXT_FIELDS: + if field in real_schema: # 工具真实 schema 声明了才注入 + injected[field] = user_context[field] + return inner_forward(**injected) # 绕过二次校验,直达执行 +``` + +> 实现注记:smolagents 的 `Tool.__call__` 会按 `self.inputs` 校验参数,包装层在校验通过后注入、再调 `forward`,避开二次校验;具体以 smolagents 1.23 的实际结构适配。 + +**安全特性**: +- 模型**看不到**约定参数 → 不会填 → 伪造路径被彻底消除(比"事后强制覆盖"更彻底); +- 约定字段的值只来自认证过的会话上下文,工具侧鉴权可以信任。 + +**为什么用参数而不是 Header**:工具声明参数即可,无需改 MCP 传输层;"没声明 = 不要求 = 不注入"天然满足"根据工具要求"。 + +**沙箱兼容**:Docker 沙箱模式下 host 工具经 `_ToolBridge` 回调(`sandbox.py` L403-436),包装层在工具对象上,两条路径都生效。 + +### 3.4 注入通道二:外部 A2A Agent + +`ExternalA2AAgentWrapper.__call__` 调用时将 `user_context` 并入 `context`(最终进入 `payload["metadata"]["user_context"]`)。外部 agent 从 metadata 读取,按需鉴权。不占用消息正文。 + +### 3.5 注入通道三:内部子 Agent + +- 子 Agent 由 `create_agent_config` 递归创建,整个智能体树共享同一个 `NexentAgent` 实例(`self.user_context` 一致),其自身的工具注入链路与父一致,无需额外处理。 +- **不合并进 `runtime_metadata`**(审计后移除):外部 A2A 的注入已由 wrapper 独立完成,合并会造成双路径冗余与每请求额外拷贝;且当前没有从 `agent.state["metadata"]` 读取 `user_context` 的消费方,保持最小化。 + +### 3.6 不做的事(边界) + +- 平台**不做**工具侧鉴权、不校验工具返回; +- 不改 smolagents 外部依赖(包装而非修改); +- 不做自定义字段映射配置(如工具想叫 `operator_email`)——一期约定参数名覆盖,映射配置留作扩展点(`ToolConfig.params` 可承载); +- 不含 northbound 等 API 层改动(透传只发生在工具调用链)。 + +## 4. 改动清单 + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `sdk/nexent/core/agents/agent_model.py` | `AgentRunInfo` 增加 `user_context: Optional[dict]` 字段 | +| 2 | `backend/agents/create_agent_info.py` | `create_agent_run_info` 中构建 `ToolUserContext`(查 TENANT_NAME / user_email / groups),放入 `AgentRunInfo` | +| 3 | `backend/database/` | 复用已有查询(`get_single_config_info` / `get_user_tenant_by_user_id` / `query_groups_by_user`),如缺少按 user_id 查 email 的聚合函数则补一个 | +| 4 | `sdk/nexent/core/agents/nexent_agent.py` | `NexentAgent` 接收 `user_context`;`create_mcp_tool` 返回包装对象(约定参数注入 + 强制覆盖) | +| 5 | `sdk/nexent/core/agents/a2a_agent_proxy.py` | `ExternalA2AAgentWrapper.__call__` 把 `user_context` 并入 context -> metadata | +| 7 | `sdk/nexent/core/agents/run_agent.py` | `agent_run_thread` 把 `AgentRunInfo.user_context` 传入 `NexentAgent` | +| 8 | 单元测试 | 注入逻辑(声明/未声明/覆盖模型值)、降级路径、A2A metadata | + +## 5. 测试方案 + +1. **单测**: + - 工具声明 `user_account` 参数 -> 注入发生且值 = 会话邮箱; + - 工具未声明约定字段 -> 不注入、不污染参数; + - 模型看不到约定字段(可见 schema 已移除,不生成),包装层在执行时补值; + - tenant_name 配置缺失 -> 回退 tenant_id;查询异常 -> 最小上下文降级。 +2. **集成验证**(本地环境): + - 本地 MCP 工具(`mcp_server.py`)定义 `user_account/tenant_name/user_groups` 参数,对话触发调用,断言收到的值与当前登录用户一致; + - A2A:观察外发请求 `metadata.user_context` 内容。 + +## 6. 待确认项 + +1. 约定参数名集合是否就用上表 6 个?(可增删) +2. 是否需要租户级开关(默认全开 / 可关闭透传)?一期建议**默认开启、无开关**,保持简单。 diff --git a/sdk/nexent/core/agents/a2a_agent_proxy.py b/sdk/nexent/core/agents/a2a_agent_proxy.py index e78065981a..87ba10ad7b 100644 --- a/sdk/nexent/core/agents/a2a_agent_proxy.py +++ b/sdk/nexent/core/agents/a2a_agent_proxy.py @@ -726,7 +726,8 @@ 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. @@ -734,6 +735,9 @@ def __init__( 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 @@ -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."}, @@ -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: diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index bee5e05ce5..6fa710ffad 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -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, diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index 2096f5a0c8..07681c48d0 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -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 @@ -218,7 +219,8 @@ def __init__(self, observer: MessageObserver, tenant_id=None, workspace_path=None, workspace_run_id=None, - minio_files=None): + minio_files=None, + user_context=None): """ Initialize the NexentAgent factory. @@ -238,6 +240,8 @@ def __init__(self, observer: MessageObserver, 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") @@ -255,6 +259,7 @@ def __init__(self, observer: MessageObserver, 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] = [] @@ -493,7 +498,7 @@ def create_mcp_tool(self, class_name): ) 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. @@ -732,7 +737,8 @@ def create_single_agent( 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( diff --git a/sdk/nexent/core/agents/run_agent.py b/sdk/nexent/core/agents/run_agent.py index 2bf4cc3629..c06542a299 100644 --- a/sdk/nexent/core/agents/run_agent.py +++ b/sdk/nexent/core/agents/run_agent.py @@ -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, @@ -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, diff --git a/sdk/nexent/core/agents/tool_user_context.py b/sdk/nexent/core/agents/tool_user_context.py new file mode 100644 index 0000000000..20427f1a69 --- /dev/null +++ b/sdk/nexent/core/agents/tool_user_context.py @@ -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 diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 2ddcdbbdbf..9a43b8a3df 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -4050,6 +4050,7 @@ async def test_create_agent_run_info_success(self): workspace_run_id=ANY, tenant_id="tenant_1", minio_files=[], + user_context=None, ) # Verify that other functions were called correctly @@ -7552,3 +7553,117 @@ def test_build_security_headers_scheme_builds_none(self): "security_credentials": {"k": "v"}, } assert _build_security_headers(agent) == {} + + +# =========================================================================== +# Tool user context (user-info pass-through for tool-side authorization) +# =========================================================================== + +class TestBuildToolUserContext: + """Tests for _build_tool_user_context: assembles caller identity for tools.""" + + @staticmethod + def _db_stubs(tenant_name_record, user_tenant_record, groups): + tenant_config_db = types.ModuleType("database.tenant_config_db") + tenant_config_db.get_single_config_info = Mock(return_value=tenant_name_record) + user_tenant_db = types.ModuleType("database.user_tenant_db") + user_tenant_db.get_user_tenant_by_user_id = Mock(return_value=user_tenant_record) + group_db = types.ModuleType("database.group_db") + group_db.query_groups_by_user = Mock(return_value=groups) + return { + "database.tenant_config_db": tenant_config_db, + "database.user_tenant_db": user_tenant_db, + "database.group_db": group_db, + } + + def test_full_context(self): + from backend.agents.create_agent_info import _build_tool_user_context + with patch.object(consts_const, "TENANT_NAME", "TENANT_NAME", create=True), \ + patch.dict(sys.modules, self._db_stubs( + {"config_value": "bug-repro"}, + {"user_email": "bug-admin@qq.com"}, + [{"group_name": "Default Group"}, {"group_name": "QA"}], + )): + ctx = _build_tool_user_context("u-1", "t-1") + assert ctx == { + "tenant_id": "t-1", + "tenant_name": "bug-repro", + "user_id": "u-1", + "user_name": "bug-admin@qq.com", + "user_account": "bug-admin@qq.com", + "user_groups": ["Default Group", "QA"], + } + + def test_tenant_name_missing_falls_back_to_tenant_id(self): + from backend.agents.create_agent_info import _build_tool_user_context + with patch.object(consts_const, "TENANT_NAME", "TENANT_NAME", create=True), \ + patch.dict(sys.modules, self._db_stubs( + None, + {"user_email": "bug-admin@qq.com"}, + [], + )): + ctx = _build_tool_user_context("u-1", "t-1") + assert ctx["tenant_name"] == "t-1" + assert ctx["user_groups"] == [] + + def test_lookup_errors_degrade_without_raising(self): + from backend.agents.create_agent_info import _build_tool_user_context + + tenant_config_db = types.ModuleType("database.tenant_config_db") + tenant_config_db.get_single_config_info = Mock(side_effect=RuntimeError("db down")) + user_tenant_db = types.ModuleType("database.user_tenant_db") + user_tenant_db.get_user_tenant_by_user_id = Mock(side_effect=RuntimeError("db down")) + group_db = types.ModuleType("database.group_db") + group_db.query_groups_by_user = Mock(side_effect=RuntimeError("db down")) + stubs = { + "database.tenant_config_db": tenant_config_db, + "database.user_tenant_db": user_tenant_db, + "database.group_db": group_db, + } + with patch.object(consts_const, "TENANT_NAME", "TENANT_NAME", create=True), \ + patch.dict(sys.modules, stubs): + ctx = _build_tool_user_context("u-1", "t-1") + # Minimal context keeps the conversation going instead of failing. + assert ctx == { + "tenant_id": "t-1", + "tenant_name": "t-1", + "user_id": "u-1", + "user_name": "", + "user_account": "", + "user_groups": [], + } + + +class TestAgentTreeNeedsUserContext: + """Tests for _agent_tree_needs_user_context build-skip condition.""" + + @staticmethod + def _cfg(tools=None, external=None, managed=None): + config = types.SimpleNamespace() + config.tools = tools or [] + config.external_a2a_agents = external or [] + config.managed_agents = managed or [] + return config + + def test_local_only_tree_skips(self): + from backend.agents.create_agent_info import _agent_tree_needs_user_context + config = self._cfg(tools=[types.SimpleNamespace(source="local"), + types.SimpleNamespace(source="builtin")]) + assert _agent_tree_needs_user_context(config) is False + + def test_mcp_tool_triggers(self): + from backend.agents.create_agent_info import _agent_tree_needs_user_context + config = self._cfg(tools=[types.SimpleNamespace(source="local"), + types.SimpleNamespace(source="mcp")]) + assert _agent_tree_needs_user_context(config) is True + + def test_external_a2a_triggers(self): + from backend.agents.create_agent_info import _agent_tree_needs_user_context + config = self._cfg(external=[types.SimpleNamespace()]) + assert _agent_tree_needs_user_context(config) is True + + def test_sub_agent_mcp_triggers(self): + from backend.agents.create_agent_info import _agent_tree_needs_user_context + sub = self._cfg(tools=[types.SimpleNamespace(source="mcp")]) + config = self._cfg(tools=[types.SimpleNamespace(source="local")], managed=[sub]) + assert _agent_tree_needs_user_context(config) is True diff --git a/test/sdk/core/agents/test_tool_user_context.py b/test/sdk/core/agents/test_tool_user_context.py new file mode 100644 index 0000000000..1b8bad9f3c --- /dev/null +++ b/test/sdk/core/agents/test_tool_user_context.py @@ -0,0 +1,167 @@ +""" +Unit tests for sdk.nexent.core.agents.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 + authenticated session right before execution. +- Those fields are hidden from the model-visible schema (removed from inputs), + so the model neither sees nor fills them. +- Tools declaring no conventional fields stay untouched. + +Uses direct module loading to bypass the sdk.nexent package __init__.py +which has heavy dependencies not needed for this module. +""" +import importlib.util +import os + +import pytest + + +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", + )) + spec = importlib.util.spec_from_file_location("tool_user_context", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +tool_user_context = _load_tool_user_context_module() +USER_CONTEXT_FIELDS = tool_user_context.USER_CONTEXT_FIELDS +apply_user_context_to_mcp_tool = tool_user_context.apply_user_context_to_mcp_tool + + +class _FakeTool: + """Minimal smolagents-like tool: an inputs dict plus a forward method.""" + + def __init__(self, inputs, forward): + self.name = "fake_tool" + self.inputs = dict(inputs) + self.forward = forward + + +SAMPLE_CONTEXT = { + "tenant_id": "t-1", + "tenant_name": "bug-repro", + "user_id": "u-1", + "user_name": "bug-admin@qq.com", + "user_account": "bug-admin@qq.com", + "user_groups": ["Default Group"], +} + + +def test_conventional_field_set(): + assert USER_CONTEXT_FIELDS == ( + "tenant_id", "tenant_name", "user_id", + "user_name", "user_account", "user_groups", + ) + + +def test_declared_fields_injected_and_hidden_from_model(): + received = {} + + def forward(query, user_account=None, user_groups=None): + received["query"] = query + received["user_account"] = user_account + received["user_groups"] = user_groups + return "ok" + + tool = _FakeTool( + { + "query": {"type": "string"}, + "user_account": {"type": "string"}, + "user_groups": {"type": "array"}, + }, + forward, + ) + wrapped = apply_user_context_to_mcp_tool(tool, SAMPLE_CONTEXT) + + # Model-visible schema no longer contains the conventional fields. + assert set(wrapped.inputs) == {"query"} + # Model only supplies business args; identity values come from the session. + assert wrapped.forward(query="hello") == "ok" + assert received["query"] == "hello" + assert received["user_account"] == "bug-admin@qq.com" + assert received["user_groups"] == ["Default Group"] + + +@pytest.mark.asyncio +async def test_async_forward_injection(): + captured = {} + + async def forward(**kwargs): + captured.update(kwargs) + return "ok" + + tool = _FakeTool( + {"query": {"type": "string"}, "tenant_name": {"type": "string"}}, + forward, + ) + wrapped = apply_user_context_to_mcp_tool(tool, SAMPLE_CONTEXT) + result = await wrapped.forward(query="hi") + assert result == "ok" + assert captured == {"query": "hi", "tenant_name": "bug-repro"} + + +def test_only_declared_fields_are_injected(): + captured = {} + + def forward(**kwargs): + captured.update(kwargs) + return "ok" + + tool = _FakeTool( + {"query": {"type": "string"}, "user_account": {"type": "string"}}, + forward, + ) + wrapped = apply_user_context_to_mcp_tool(tool, SAMPLE_CONTEXT) + wrapped.forward(query="hi") + # user_groups is not declared by the tool, so it must not be injected. + assert captured == {"query": "hi", "user_account": "bug-admin@qq.com"} + + +def test_tool_without_conventional_fields_untouched(): + def forward(query): + return query + + tool = _FakeTool({"query": {"type": "string"}}, forward) + result = apply_user_context_to_mcp_tool(tool, SAMPLE_CONTEXT) + assert result is tool + assert result.inputs == {"query": {"type": "string"}} + assert result.forward is forward + assert not getattr(tool, "_nexent_user_context_wrapped", False) + + +def test_missing_user_context_untouched(): + def forward(query, user_account=None): + return query + + tool = _FakeTool( + {"query": {"type": "string"}, "user_account": {"type": "string"}}, + forward, + ) + for empty_context in (None, {}): + result = apply_user_context_to_mcp_tool(tool, empty_context) + assert result is tool + assert "user_account" in result.inputs + + +def test_wrapping_is_idempotent(): + captured = {} + + def forward(**kwargs): + captured.update(kwargs) + return "ok" + + tool = _FakeTool( + {"query": {"type": "string"}, "user_id": {"type": "string"}}, + forward, + ) + first = apply_user_context_to_mcp_tool(tool, SAMPLE_CONTEXT) + second = apply_user_context_to_mcp_tool(first, SAMPLE_CONTEXT) + assert second is first + second.forward(query="hi") + # Injection happens exactly once even when wrapping is attempted twice. + assert captured == {"query": "hi", "user_id": "u-1"} From 2861184f200b7b96a7ede49b8ed6eabac0b6fd50 Mon Sep 17 00:00:00 2001 From: Eddiward_yukika <145945731+EDDIWARD@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:52:13 +0800 Subject: [PATCH 2/4] test: update NexentAgent exact-call assertions for user_context kwarg --- test/sdk/core/agents/test_run_agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/sdk/core/agents/test_run_agent.py b/test/sdk/core/agents/test_run_agent.py index 7cabea5ba5..bf6b69f165 100644 --- a/test/sdk/core/agents/test_run_agent.py +++ b/test/sdk/core/agents/test_run_agent.py @@ -337,6 +337,7 @@ def test_agent_run_thread_local_flow(basic_agent_run_info, monkeypatch): model_config_list=basic_agent_run_info.model_config_list, stop_event=basic_agent_run_info.stop_event, redis_client=basic_agent_run_info.redis_client, + user_context=basic_agent_run_info.user_context, sandbox_config=None, minio_client=None, conversation_id=basic_agent_run_info.conversation_id, @@ -450,6 +451,7 @@ def test_agent_run_thread_mcp_flow(basic_agent_run_info, mock_memory_context, mo stop_event=basic_agent_run_info.stop_event, mcp_tool_collection=mock_tool_collection, redis_client=basic_agent_run_info.redis_client, + user_context=basic_agent_run_info.user_context, sandbox_config=None, minio_client=None, conversation_id=basic_agent_run_info.conversation_id, From 74e760dc118e54512971d45abeb79bfcec85cc36 Mon Sep 17 00:00:00 2001 From: Eddiward_yukika <145945731+EDDIWARD@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:08:14 +0800 Subject: [PATCH 3/4] test: cover user_context injection branches for codecov patch coverage --- test/backend/agents/test_create_agent_info.py | 23 +++++++++++ test/sdk/core/agents/test_a2a_agent_proxy.py | 40 +++++++++++++++++++ .../sdk/core/agents/test_tool_user_context.py | 13 ++++++ 3 files changed, 76 insertions(+) diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 9a43b8a3df..0c6ad18daa 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -7667,3 +7667,26 @@ def test_sub_agent_mcp_triggers(self): sub = self._cfg(tools=[types.SimpleNamespace(source="mcp")]) config = self._cfg(tools=[types.SimpleNamespace(source="local")], managed=[sub]) assert _agent_tree_needs_user_context(config) is True + + +class TestResolveToolUserContext: + """Tests for _resolve_tool_user_context defensive resolver.""" + + def test_unneeded_tree_returns_none(self): + from backend.agents.create_agent_info import _resolve_tool_user_context + config = types.SimpleNamespace( + tools=[types.SimpleNamespace(source="local")], + external_a2a_agents=[], + managed_agents=[], + ) + assert _resolve_tool_user_context(config, "u-1", "t-1") is None + + def test_build_failure_degrades_to_none(self): + """A tree needing context but failing lookups degrades to None, never raises.""" + from backend.agents.create_agent_info import _resolve_tool_user_context + config = types.SimpleNamespace( + tools=[types.SimpleNamespace(source="mcp")], + external_a2a_agents=[], + managed_agents=[], + ) + assert _resolve_tool_user_context(config, "u-1", "t-1") is None diff --git a/test/sdk/core/agents/test_a2a_agent_proxy.py b/test/sdk/core/agents/test_a2a_agent_proxy.py index ee8b638168..d798fa201a 100644 --- a/test/sdk/core/agents/test_a2a_agent_proxy.py +++ b/test/sdk/core/agents/test_a2a_agent_proxy.py @@ -1752,6 +1752,46 @@ def test_call_forwards_runtime_metadata_to_message_context(self): metadata["tenant"]["region"] = "changed" assert wrapper.get_runtime_metadata() == {"tenant": {"region": "cn"}} + + def test_call_merges_user_context_into_message_context(self): + """The caller user context is merged into the forwarded message context.""" + wrapper = ExternalA2AAgentWrapper( + self._make_info(), + user_context={ + "user_account": "bug-admin@qq.com", + "user_groups": ["Default Group"], + }, + ) + wrapper.set_runtime_metadata({"tenant": {"region": "cn"}}) + + with patch.object(ExternalA2AAgentProxy, "sync_call", return_value="ok") as sync_call: + assert wrapper(task="do something") == "ok" + + sync_call.assert_called_once_with( + "do something", + [], + context={ + "tenant": {"region": "cn"}, + "user_context": { + "user_account": "bug-admin@qq.com", + "user_groups": ["Default Group"], + }, + }, + ) + + def test_user_context_is_isolated_copy(self): + """Mutating the source dict after construction must not leak into calls.""" + source = {"user_account": "bug-admin@qq.com"} + wrapper = ExternalA2AAgentWrapper(self._make_info(), user_context=source) + source["user_account"] = "forged@evil.com" + + with patch.object(ExternalA2AAgentProxy, "sync_call", return_value="ok") as sync_call: + wrapper(task="do something") + + assert sync_call.call_args[1]["context"]["user_context"] == { + "user_account": "bug-admin@qq.com" + } + def test_set_runtime_metadata_rejects_non_dict(self): """set_runtime_metadata must reject values that are not mappings.""" wrapper = ExternalA2AAgentWrapper(self._make_info()) diff --git a/test/sdk/core/agents/test_tool_user_context.py b/test/sdk/core/agents/test_tool_user_context.py index 1b8bad9f3c..7a25f109e4 100644 --- a/test/sdk/core/agents/test_tool_user_context.py +++ b/test/sdk/core/agents/test_tool_user_context.py @@ -165,3 +165,16 @@ def forward(**kwargs): second.forward(query="hi") # Injection happens exactly once even when wrapping is attempted twice. assert captured == {"query": "hi", "user_id": "u-1"} + + +def test_non_dict_inputs_untouched(): + """Tools without a dict-shaped inputs schema are returned unchanged.""" + + def forward(**kwargs): + return "ok" + + tool = _FakeTool({"query": {"type": "string"}}, forward) + tool.inputs = "not-a-dict" + result = apply_user_context_to_mcp_tool(tool, SAMPLE_CONTEXT) + assert result is tool + assert not getattr(tool, "_nexent_user_context_wrapped", False) From d882c34d180356788d08b38d0a5da8e4112b313d Mon Sep 17 00:00:00 2001 From: Eddiward_yukika <145945731+EDDIWARD@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:27:47 +0800 Subject: [PATCH 4/4] docs: remove internal tool user context design --- doc/tool-user-context-design.md | 175 -------------------------------- 1 file changed, 175 deletions(-) delete mode 100644 doc/tool-user-context-design.md diff --git a/doc/tool-user-context-design.md b/doc/tool-user-context-design.md deleted file mode 100644 index 0bd66e20c4..0000000000 --- a/doc/tool-user-context-design.md +++ /dev/null @@ -1,175 +0,0 @@ -# 工具用户信息透传 — 设计文档 - -> 分支:`edward/feature-tool-user-context`(worktree:`C:\Users\Edward\work\nexent-tool-user-context`) -> 基线:`origin/develop`(8f20f87cf,v2.5.0+) - -## 1. 需求 - -智能体调用 MCP 工具和 Agent(子智能体 / 外部 A2A)时,**根据工具要求**传入调用者的用户信息: - -| 字段 | 含义 | -|------|------| -| 租户名(tenant_name) | 租户显示名,如 `bug-repro` | -| 用户名(user_name) | 用户显示名(当前系统中即邮箱) | -| 用户账号(user_account) | 用户邮箱,如 `bug-admin@qq.com` | -| 用户组(user_groups) | 用户所属组名列表,如 `["Default Group"]` | - -工具用这些信息在**访问数据前自行鉴权**。 - -**边界**:平台本身不做鉴权、不校验工具侧权限,只负责把**经过平台认证的会话身份**如实透传给工具。 - -## 2. 现状调研结论 - -### 2.1 用户信息的数据来源(已全部确认) - -| 字段 | 存储位置 | 获取方式 | -|------|----------|----------| -| tenant_id | 会话上下文(JWT / access_key 解析) | 执行入口已有 | -| tenant_name | `tenant_config_t`(`config_key='TENANT_NAME'`) | `get_single_config_info(tenant_id, TENANT_NAME)`,缺失时回退 `tenant_id` | -| user_id | 会话上下文 | 执行入口已有 | -| user_name / user_account | `user_tenant_t.user_email` | `get_user_tenant_by_user_id(user_id)` | -| user_groups | `tenant_group_info_t` join `tenant_group_user_t` | `query_groups_by_user(user_id)` -> `group_name` 列表 | - -### 2.2 运行时工具调用链路 - -``` -run_agent_stream(user_id, tenant_id) [backend agent_service] - -> prepare_agent_run -> create_agent_run_info [backend create_agent_info] - -> AgentRunInfo -> agent_run_thread [sdk run_agent] - -> NexentAgent(user_id, tenant_id) [sdk nexent_agent] - |-- MCP 工具: create_tool -> create_mcp_tool(smolagents MCPClientTool) - | -> 模型生成代码 -> MCPClientTool.forward(**kwargs) -> session.call_tool(name, kwargs) - |-- 内部子 Agent: SubAgentToolWrapper.__call__(task=...) - +-- 外部 A2A: ExternalA2AAgentWrapper.__call__(task=...) - -> _build_message_payload(query, context) -> payload["metadata"] -``` - -关键事实: - -1. **`NexentAgent.create_mcp_tool`(`sdk/nexent/core/agents/nexent_agent.py` L487-496)是 MCP 工具的唯一收口点**,当前不注入任何用户上下文(对比:本地工具已有注入 `tenant_id/user_id` 的先例)。 -2. `user_id/tenant_id` 已逐层传到 `NexentAgent`(L253-254),**取用户身份不难,难的是取全四个字段并送到工具执行处**。 -3. 外部 A2A 已有 `metadata` 透传通道(`runtime_metadata` -> `payload["metadata"]`)。 -4. 内部子 Agent 与父 Agent 使用相同的 `user_id/tenant_id` 递归创建(`create_agent_config` L981-1007),上下文天然一致。 - -## 3. 总体设计 - -### 3.1 架构:构建 -> 传递 -> 注入 - -``` -[构建] create_agent_run_info(执行入口,每个请求重建) - 查询 4 字段 -> ToolUserContext - | -[传递] AgentRunInfo.user_context -> NexentAgent.user_context - | -[注入] |-- MCP 工具:create_mcp_tool 包装层(约定参数名注入,对模型隐藏) - |-- 外部 A2A:ExternalA2AAgentWrapper 独立注入 message.metadata["user_context"] - +-- 内部子 Agent:同一 NexentAgent 实例递归创建,工具注入链路天然一致 -``` - -### 3.2 用户上下文数据结构 - -```python -class ToolUserContext(BaseModel): - """透传给工具的用户信息。平台不鉴权,工具在访问数据前自行鉴权。""" - tenant_id: str - tenant_name: str # TENANT_NAME 配置,缺失回退 tenant_id - user_id: str - user_name: str # = user_email - user_account: str # = user_email - user_groups: list[str] # 组名列表,如 ["Default Group"] -``` - -构建位置:`backend/agents/create_agent_info.py` 的 `create_agent_run_info`(此处 user_id/tenant_id 已就位)。 - -**条件构建(性能)**:仅当智能体树包含 MCP 工具(任意层级 `source == "mcp"`)或外部 A2A Agent 时才执行构建(`_agent_tree_needs_user_context` 递归判断);纯本地/内置工具的对话**零额外 DB 查询**,高并发场景避免每请求 3 次无谓查询。 - -**防御性降级**:`_resolve_tool_user_context` 整体捕获异常返回 `None`;单个字段查询失败降级为最小上下文——**任何情况下都不阻断对话**。 - -### 3.3 注入通道一:MCP 工具(约定参数名,对模型隐藏) - -**核心原则:用户信息完全不进入模型视野——平台在模型传输外面包一层注入。** 模型不知道这些参数存在、不会为其生成值,也就无从伪造。 - -**两份 schema**: -- **模型可见 schema**:构建智能体工具列表时,从工具 `inputs` 中移除约定字段——模型看到的提示词/工具说明不含这些参数(不占 token、不引起困惑、无法填充); -- **实际调用 schema**:MCP 服务端的 `inputSchema` 保持不变(它即工具的"要求声明"),真正执行时由包装层把约定字段值补进参数。 - -**声明方式**:工具的 MCP 服务端 `inputSchema` 中定义了**约定字段名**,即视为"该工具要求此用户信息": - -| 约定参数名 | 注入值 | -|-----------|--------| -| `tenant_id` | tenant_id | -| `tenant_name` | 租户显示名 | -| `user_id` | user_id | -| `user_name` | user_email | -| `user_account` | user_email | -| `user_groups` | 组名列表(JSON 数组) | - -**注入点**:`NexentAgent.create_mcp_tool` 返回的工具对象包一层代理(属性转发可参考 `SubAgentToolWrapper` 的先例): - -```python -# 1) 构建工具列表时:从模型可见 schema 移除约定字段 -tool.inputs = {k: v for k, v in tool.inputs.items() if k not in USER_CONTEXT_FIELDS} - -# 2) 工具执行时:模型只生成业务参数,包装层在转发前补入约定值 -def __call__(self, **model_kwargs): - # model_kwargs 不含约定字段(模型不可见),原参数校验照常通过 - injected = dict(model_kwargs) - for field in USER_CONTEXT_FIELDS: - if field in real_schema: # 工具真实 schema 声明了才注入 - injected[field] = user_context[field] - return inner_forward(**injected) # 绕过二次校验,直达执行 -``` - -> 实现注记:smolagents 的 `Tool.__call__` 会按 `self.inputs` 校验参数,包装层在校验通过后注入、再调 `forward`,避开二次校验;具体以 smolagents 1.23 的实际结构适配。 - -**安全特性**: -- 模型**看不到**约定参数 → 不会填 → 伪造路径被彻底消除(比"事后强制覆盖"更彻底); -- 约定字段的值只来自认证过的会话上下文,工具侧鉴权可以信任。 - -**为什么用参数而不是 Header**:工具声明参数即可,无需改 MCP 传输层;"没声明 = 不要求 = 不注入"天然满足"根据工具要求"。 - -**沙箱兼容**:Docker 沙箱模式下 host 工具经 `_ToolBridge` 回调(`sandbox.py` L403-436),包装层在工具对象上,两条路径都生效。 - -### 3.4 注入通道二:外部 A2A Agent - -`ExternalA2AAgentWrapper.__call__` 调用时将 `user_context` 并入 `context`(最终进入 `payload["metadata"]["user_context"]`)。外部 agent 从 metadata 读取,按需鉴权。不占用消息正文。 - -### 3.5 注入通道三:内部子 Agent - -- 子 Agent 由 `create_agent_config` 递归创建,整个智能体树共享同一个 `NexentAgent` 实例(`self.user_context` 一致),其自身的工具注入链路与父一致,无需额外处理。 -- **不合并进 `runtime_metadata`**(审计后移除):外部 A2A 的注入已由 wrapper 独立完成,合并会造成双路径冗余与每请求额外拷贝;且当前没有从 `agent.state["metadata"]` 读取 `user_context` 的消费方,保持最小化。 - -### 3.6 不做的事(边界) - -- 平台**不做**工具侧鉴权、不校验工具返回; -- 不改 smolagents 外部依赖(包装而非修改); -- 不做自定义字段映射配置(如工具想叫 `operator_email`)——一期约定参数名覆盖,映射配置留作扩展点(`ToolConfig.params` 可承载); -- 不含 northbound 等 API 层改动(透传只发生在工具调用链)。 - -## 4. 改动清单 - -| # | 文件 | 改动 | -|---|------|------| -| 1 | `sdk/nexent/core/agents/agent_model.py` | `AgentRunInfo` 增加 `user_context: Optional[dict]` 字段 | -| 2 | `backend/agents/create_agent_info.py` | `create_agent_run_info` 中构建 `ToolUserContext`(查 TENANT_NAME / user_email / groups),放入 `AgentRunInfo` | -| 3 | `backend/database/` | 复用已有查询(`get_single_config_info` / `get_user_tenant_by_user_id` / `query_groups_by_user`),如缺少按 user_id 查 email 的聚合函数则补一个 | -| 4 | `sdk/nexent/core/agents/nexent_agent.py` | `NexentAgent` 接收 `user_context`;`create_mcp_tool` 返回包装对象(约定参数注入 + 强制覆盖) | -| 5 | `sdk/nexent/core/agents/a2a_agent_proxy.py` | `ExternalA2AAgentWrapper.__call__` 把 `user_context` 并入 context -> metadata | -| 7 | `sdk/nexent/core/agents/run_agent.py` | `agent_run_thread` 把 `AgentRunInfo.user_context` 传入 `NexentAgent` | -| 8 | 单元测试 | 注入逻辑(声明/未声明/覆盖模型值)、降级路径、A2A metadata | - -## 5. 测试方案 - -1. **单测**: - - 工具声明 `user_account` 参数 -> 注入发生且值 = 会话邮箱; - - 工具未声明约定字段 -> 不注入、不污染参数; - - 模型看不到约定字段(可见 schema 已移除,不生成),包装层在执行时补值; - - tenant_name 配置缺失 -> 回退 tenant_id;查询异常 -> 最小上下文降级。 -2. **集成验证**(本地环境): - - 本地 MCP 工具(`mcp_server.py`)定义 `user_account/tenant_name/user_groups` 参数,对话触发调用,断言收到的值与当前登录用户一致; - - A2A:观察外发请求 `metadata.user_context` 内容。 - -## 6. 待确认项 - -1. 约定参数名集合是否就用上表 6 个?(可增删) -2. 是否需要租户级开关(默认全开 / 可关闭透传)?一期建议**默认开启、无开关**,保持简单。