mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
Remove MCP wire suppression; keep specialist binding only.
Drop Admin 线侧策略 UI/APIs and gut tool_wire_policy to schema+size prep so MCP tools are gated solely by expert bindings.
This commit is contained in:
parent
4277ad6b14
commit
fe29b43695
10 changed files with 127 additions and 2211 deletions
|
|
@ -239,50 +239,7 @@
|
|||
- 作用:追加给 filesystem MCP 的根目录
|
||||
- 生效:`oclaw/tools/mcp/filesystem_argv.py`
|
||||
|
||||
- `AIA_MCP_WIRE_USAGE_POLICY`
|
||||
- 默认:空(按 base_url 继承)
|
||||
- 作用:是否启用 MCP 工具线侧分层策略
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_PENALTY_DISABLE`
|
||||
- 默认:`0`
|
||||
- 作用:禁用线侧陈旧惩罚
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_TOP_N_FULL`
|
||||
- 默认:`20`
|
||||
- 作用:全量上送工具数量
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_STALE_HOURS`
|
||||
- 默认:`3`
|
||||
- 作用:陈旧判定小时阈值
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_PENALTY_MINUTES`
|
||||
- 默认:`30`
|
||||
- 作用:惩罚窗口分钟数
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MEDIUM_RANK_START`
|
||||
- 默认:`21`
|
||||
- 作用:中等层起始排名
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MEDIUM_RANK_END`
|
||||
- 默认:`50`
|
||||
- 作用:中等层结束排名
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MEDIUM_DESC_CHARS`
|
||||
- 默认:`520`
|
||||
- 作用:中等层描述截断长度
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MINIMAL_DESC_CAP`
|
||||
- 默认:`80`
|
||||
- 作用:最小层描述长度
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
> 历史 `AIA_MCP_WIRE_*`(线侧分层/惩罚)环境变量已废弃;MCP 工具可见性仅由专家绑定控制。
|
||||
|
||||
## LLM 工具载荷与模型兼容
|
||||
|
||||
|
|
|
|||
|
|
@ -187,59 +187,15 @@ for raw in sys.stdin:
|
|||
- 在 **【4】已安装 MCP 服务** 使用 **Export JSON (download)**,可下载当前库中**全部**已安装 MCP 的可重装 JSON(`servers` 包 + `exported_at`)。
|
||||
- 每次 **安装、重装、卸载且删除库记录、Delete** 成功后,会刷新 **`oclaw/_local/mcp_registry_migrated.json`**(与导出内容同结构,便于换库/换机后把文件粘回 **Install from JSON** 或 `python scripts/seed_mcp_registry.py path/to/file.json` 注意 seed 会跑 npm/pypi 安装步骤,与 `dry_run` 等字段一致)。该文件建议加入 `.gitignore`(如未忽略),避免本机差异被误提交;密钥仍放在 `oclaw/_local/mcp_local.env` 等环境变量,不在此 JSON 中。
|
||||
|
||||
### 3.3 MCP 工具线侧策略(上送压缩与惩罚)
|
||||
### 3.3 MCP 工具可见性(仅绑定)
|
||||
|
||||
在管理台 **Plugins(插件)** 页中的 **「线侧策略」** 折叠区块(**【6】全局参数**、**【7】已安装工具**)可配置发往 LLM 的 OpenAI 格式 `tools[]` 的**分层压缩**、**全局闲置惩罚**,以及**按完整工具名** `mcp__{server_id}__{tool_name}` 的策略(与模型 `base_url` 解耦时,将 **wire_policy** 设为 `always`)。**【7】** 中每个工具的等级为**数字输入框**(任意整数 1–9998;留空表示未配置;0 与留空语义不同,见下表)。
|
||||
线侧「打压」策略(分层压缩 / 闲置惩罚 / 9999 永禁 / role_mode)**已移除**。
|
||||
|
||||
**【7】表格筛选与专家列(管理台)**
|
||||
发给 LLM 的 MCP 工具范围只由 **专家 ↔ MCP server 绑定** 决定:
|
||||
|
||||
- 表头**第二行**为各列筛选输入(子串匹配,不区分大小写):`server`、`tool`、`wire_name`、**专家**、`count`(上下界)、`last_ts`、惩罚/解封说明、策略**等级**(上下界)。分页条数按**筛选后**结果计算。
|
||||
- **专家**列由本页当前 **MCP 专家绑定草稿**(`mapping`)与后端返回的 `available_specialists` 合并推导:某 `server_id` 出现在哪些专家的绑定列表中,即显示为逗号分隔的专家 id;可按专家子串筛选。勾选「仅已勾选」时只显示当前勾选的行(勾选集合在筛选、翻页间保留)。
|
||||
- 同页 **【8】专家 MCP 绑定看板(自动)**:按当前草稿与**已安装 MCP** 各服务的 `tools` 列表,汇总每个专家绑定的 **MCP 个数** 与 **tool 条数**(各已绑定 server 的 `tools` 长度之和);专家集合随 `available_specialists` 与 `mapping` 中的键自动扩展,无需写死。
|
||||
- **【9】MCP 专家绑定(编辑)** 为原绑定编辑区(勾选、反向视图、保存);与【8】看板联动,改绑定后看板即时刷新(无需单独保存看板)。
|
||||
|
||||
**持久化(SQLite `app_setting`)**
|
||||
|
||||
| 键 | 含义 |
|
||||
| --- | --- |
|
||||
| `mcp_tool_wire_admin_config` | JSON:全局参数 + `wire_policy`(`inherit` / `always` / `never`)、`penalty_disable` 等 |
|
||||
| `mcp_tool_wire_tool_policies` | JSON:`{ "mcp__sid__tool": 等级 }` |
|
||||
| `mcp_tool_wire_penalty_state` | JSON:各工具惩罚状态机(`phase`、`omit_until`、`wave_ts`、`kind`),由运行时维护,一般无需手改 |
|
||||
|
||||
**`wire_policy`**
|
||||
|
||||
- `inherit`:与原先一致,默认在 DashScope 兼容 URL 上启用线侧策略;其它环境变量 `OPS_MCP_WIRE_*` 仍可作为默认值来源。
|
||||
- `always`:**不依赖 URL**,始终启用分层与惩罚逻辑(适合非 DashScope 网关也要控 payload)。
|
||||
- `never`:关闭分层/惩罚逻辑;**等级 `9999` 永久封禁仍会过滤该工具**(不上送)。
|
||||
|
||||
**按工具等级(`mcp_tool_wire_tool_policies`)**
|
||||
|
||||
| 配置 | 库中是否存在键 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| **未配置**(管理台留空 / `GET` 中 `policy_level` 为 `null`、`policy_in_db` 为 `false`) | 否 | **自动走全局**:参与用量排名与分层压缩;适用**全局**闲置小时与罚时长;可被 **Top N 全量**豁免全局闲置惩罚。新安装 MCP 在 **Sync Tools** 后出现新 `wire_name`,默认即为此状态,无需手工登记。 |
|
||||
| **显式 `0`** | 是 | **不参与**全局闲置 omission;仍参与用量分层。与「未配置」不同。 |
|
||||
| **显式 `1`~`9998`** | 是 | 与 Top N **无关**:距上次成功调用超过 **N×10 分钟** 视为闲置,进入罚时 **N×10 分钟** 的上送 omission;罚满后需再次闲置达到阈值才会再罚(状态与 `last_ts` / `kind` 对齐)。 |
|
||||
| **显式 `9999`** | 是 | **永久**从线侧 `tools[]` 中移除(彻底封禁)。 |
|
||||
|
||||
**生效优先级(同一工具上的概念顺序,便于排障)**
|
||||
|
||||
1. **`9999` 永久封禁**(若已写入 `mcp_tool_wire_tool_policies`):在组装 `tools[]` 的较早阶段即剔除,不进入后续分层与动态惩罚状态机。
|
||||
2. **显式 `1`~`9998`**:走按工具闲置/罚分钟逻辑,**不享受** Top N 对「全局惩罚」的豁免。
|
||||
3. **显式 `0`**:跳过全局闲置 omission,仍走压缩档位。
|
||||
4. **未配置**:走全局线侧逻辑(含全局闲置与 Top N 豁免等),由 `prepare_openai_tools_for_llm_api` 与 `mcp_tool_wire_admin_config` / 环境变量共同决定。
|
||||
|
||||
`wire_policy=never` 时关闭分层与动态惩罚,但 **`9999` 仍会过滤** 对应工具。
|
||||
|
||||
全局闲置小时、罚时长(分钟)、Top N 全量、medium 档位等,在 **【6】** 中可调;未写入 `app_setting` 的项继续沿用环境变量(见仓库根 `data/mcp_local.env.example` 中 `OPS_MCP_WIRE_*`)。
|
||||
|
||||
**Admin HTTP API**(需 `admin:tenant:write`,与 MCP 安装类接口一致)
|
||||
|
||||
- `GET /admin/api/mcp/tool-wire` — 返回合并后的 `config`、当前 `policies`、`penalty_state`,以及已安装 MCP 工具列表(每条含 `policy_level`:`null` 表示未在库中配置,`policy_in_db` 标明是否持久化过)及惩罚/解封说明。
|
||||
- `POST /admin/api/mcp/tool-wire/config` — 保存全局参数(部分字段可增量合并)。
|
||||
- `POST /admin/api/mcp/tool-wire/policies` — body:`{ "policies": { "mcp__...": 整数等级 }, "clears": ["mcp__...", ...](可选) }`。先按 `clears` 从已存策略中**删除键**(用于管理台留空后恢复「未配置」),再合并 `policies`。
|
||||
- `POST /admin/api/mcp/tool-wire/policies/batch` — body:`{ "level": 等级, "wire_names": ["mcp__...", ...] }`,批量写入策略。
|
||||
|
||||
实现代码:`oclaw/platform/llm/tool_wire_policy.py`;在发 Chat Completions 前由 `prepare_openai_tools_for_llm_api` 应用。
|
||||
- 管理台 Plugins:**【6】专家 MCP 绑定看板**、**【7】MCP 专家绑定(编辑)**
|
||||
- 持久化键:`mcp_specialist_server_binding`(及粗粒度兜底 `mcp_allowed_specialists` / `AIA_MCP_SPECIALISTS`)
|
||||
- 运行时:`materialize_mcp_tools_for_specialist`;上送前仅做 schema complete 与可选 JSON 体积压缩(`prepare_openai_tools_for_llm_api`)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -584,12 +584,10 @@ def build_admin_router() -> APIRouter:
|
|||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
|
||||
from svc.llm.tool_wire_policy import load_role_mode_for_role, load_tool_policies_dict_for_role
|
||||
from runtime.tools.exposure_plan import build_internal_tool_specs, build_llm_tools_plan
|
||||
|
||||
roles = _ordered_roles()
|
||||
items: list[dict[str, Any]] = []
|
||||
total_perm_ban = 0
|
||||
total_wired = 0
|
||||
total_internal = 0
|
||||
for role in roles:
|
||||
|
|
@ -602,15 +600,12 @@ def build_admin_router() -> APIRouter:
|
|||
include_mcp=True,
|
||||
preview_internal=True,
|
||||
)
|
||||
policies = load_tool_policies_dict_for_role(store, role=role)
|
||||
perm_ban = len([k for k, v in policies.items() if str(k).startswith("mcp__") and int(v) == 9999])
|
||||
total_perm_ban += perm_ban
|
||||
total_wired += len(llm_plan.tools_wired)
|
||||
total_internal += len(internal_specs)
|
||||
items.append(
|
||||
{
|
||||
"role": role,
|
||||
"role_mode": load_role_mode_for_role(store, role=role),
|
||||
"role_mode": "unrestricted",
|
||||
"internal_count": len(internal_specs),
|
||||
"internal_public_count": int(internal_diag.get("public_count") or 0),
|
||||
"internal_expert_count": int(internal_diag.get("expert_count") or 0),
|
||||
|
|
@ -619,9 +614,9 @@ def build_admin_router() -> APIRouter:
|
|||
"removed_total": len(llm_plan.removed_names),
|
||||
"removed_mcp_total": len(llm_plan.removed_mcp_names),
|
||||
"changed_total": len(llm_plan.changed_names),
|
||||
"policy_perm_ban_9999": perm_ban,
|
||||
"policy_perm_ban_9999": 0,
|
||||
"mcp_enabled": bool(llm_plan.mcp_enabled),
|
||||
"wire_policy_effective": bool(llm_plan.wire_policy_effective),
|
||||
"wire_policy_effective": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -632,7 +627,7 @@ def build_admin_router() -> APIRouter:
|
|||
"summary": {
|
||||
"total_internal_tools": total_internal,
|
||||
"total_wired_tools": total_wired,
|
||||
"total_perm_ban_9999": total_perm_ban,
|
||||
"total_perm_ban_9999": 0,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
|
@ -644,7 +639,7 @@ def build_admin_router() -> APIRouter:
|
|||
max_json_bytes: int | None = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview the final tools injected to LLM for a role (internal + MCP + wire policy)."""
|
||||
"""Preview the final tools injected to LLM for a role (internal + MCP binding)."""
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
|
|
@ -3101,283 +3096,6 @@ def build_admin_router() -> APIRouter:
|
|||
"calls": store.list_mcp_tool_call_logs(server_id=server_id, limit=limit),
|
||||
}
|
||||
|
||||
@router.get("/admin/api/mcp/tool-wire")
|
||||
def api_mcp_tool_wire_get(
|
||||
role: str | None = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
from svc.llm.tool_wire_policy import build_tool_wire_snapshot
|
||||
|
||||
return build_tool_wire_snapshot(store, role=str(role or "").strip().lower() or None)
|
||||
|
||||
@router.post("/admin/api/mcp/tool-wire/config")
|
||||
def api_mcp_tool_wire_config_save(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
from svc.llm.tool_wire_policy import SETTINGS_KEY_ADMIN_CONFIG, load_merged_admin_config
|
||||
|
||||
payload = payload or {}
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG)
|
||||
cur: dict[str, Any] = {}
|
||||
if raw:
|
||||
try:
|
||||
cur = json.loads(raw) if isinstance(raw, str) else {}
|
||||
except Exception:
|
||||
cur = {}
|
||||
if not isinstance(cur, dict):
|
||||
cur = {}
|
||||
if "wire_policy" in payload:
|
||||
wp = str(payload.get("wire_policy") or "").strip().lower()
|
||||
if wp in ("inherit", "always", "never"):
|
||||
cur["wire_policy"] = wp
|
||||
if "top_n_full" in payload:
|
||||
cur["top_n_full"] = max(3, min(80, int(payload.get("top_n_full") or 20)))
|
||||
if "stale_hours" in payload:
|
||||
cur["stale_hours"] = max(0.25, min(720.0, float(payload.get("stale_hours") or 3)))
|
||||
if "penalty_minutes" in payload:
|
||||
cur["penalty_minutes"] = max(1.0, min(24 * 60, float(payload.get("penalty_minutes") or 30)))
|
||||
if "medium_rank_start" in payload:
|
||||
cur["medium_rank_start"] = int(payload.get("medium_rank_start") or 21)
|
||||
if "medium_rank_end" in payload:
|
||||
cur["medium_rank_end"] = int(payload.get("medium_rank_end") or 50)
|
||||
if "medium_desc_chars" in payload:
|
||||
cur["medium_desc_chars"] = max(80, min(4000, int(payload.get("medium_desc_chars") or 520)))
|
||||
if "minimal_desc_cap" in payload:
|
||||
cur["minimal_desc_cap"] = max(0, min(2000, int(payload.get("minimal_desc_cap") or 80)))
|
||||
if "penalty_disable" in payload:
|
||||
cur["penalty_disable"] = bool(payload.get("penalty_disable"))
|
||||
store.set_setting(SETTINGS_KEY_ADMIN_CONFIG, json.dumps(cur, ensure_ascii=False))
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="mcp_tool_wire_config_update",
|
||||
target_type="app_setting",
|
||||
target_id=SETTINGS_KEY_ADMIN_CONFIG,
|
||||
status="ok",
|
||||
detail={"keys": list(cur.keys())},
|
||||
)
|
||||
return {"ok": True, "config": load_merged_admin_config(store)}
|
||||
|
||||
@router.post("/admin/api/mcp/tool-wire/role-mode")
|
||||
def api_mcp_tool_wire_role_mode_save(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
from svc.llm.tool_wire_policy import SETTINGS_KEY_ROLE_MODE_BY_ROLE
|
||||
|
||||
payload = payload or {}
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
role = str(payload.get("role") or "").strip().lower()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail="role_required")
|
||||
valid_roles = set(_ordered_mcp_roles())
|
||||
if role not in valid_roles:
|
||||
raise HTTPException(status_code=400, detail="invalid_role")
|
||||
mode = str(payload.get("mode") or "").strip().lower()
|
||||
if mode not in {"restricted", "unrestricted", "forbidden"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_mode")
|
||||
raw = str(store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "").strip() or "{}"
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
obj = {}
|
||||
if not isinstance(obj, dict):
|
||||
obj = {}
|
||||
obj[role] = mode
|
||||
store.set_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE, json.dumps(obj, ensure_ascii=False))
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="mcp_tool_wire_role_mode_update",
|
||||
target_type="app_setting",
|
||||
target_id=f"{SETTINGS_KEY_ROLE_MODE_BY_ROLE}:{role}",
|
||||
status="ok",
|
||||
detail={"role": role, "mode": mode},
|
||||
)
|
||||
return {"ok": True, "role": role, "mode": mode}
|
||||
|
||||
@router.post("/admin/api/mcp/tool-wire/penalty/reset")
|
||||
def api_mcp_tool_wire_penalty_reset(
|
||||
role: str | None = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
from svc.llm.tool_wire_policy import SETTINGS_KEY_PENALTY_STATE, SETTINGS_KEY_PENALTY_STATE_BY_ROLE
|
||||
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
store.set_setting(SETTINGS_KEY_PENALTY_STATE, "{}")
|
||||
target_id = SETTINGS_KEY_PENALTY_STATE
|
||||
else:
|
||||
# Reset only one role's penalty bucket.
|
||||
raw = str(store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "").strip() or "{}"
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
obj = {}
|
||||
if not isinstance(obj, dict):
|
||||
obj = {}
|
||||
obj[r] = {}
|
||||
store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(obj, ensure_ascii=False))
|
||||
target_id = f"{SETTINGS_KEY_PENALTY_STATE_BY_ROLE}:{r}"
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="mcp_tool_wire_penalty_reset",
|
||||
target_type="app_setting",
|
||||
target_id=target_id,
|
||||
status="ok",
|
||||
detail={"reset": True, "role": r},
|
||||
)
|
||||
return {"ok": True, "penalty_state": {}, "role": r}
|
||||
|
||||
@router.post("/admin/api/mcp/tool-wire/policies")
|
||||
def api_mcp_tool_wire_policies_save(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
from svc.llm.tool_wire_policy import (
|
||||
SETTINGS_KEY_TOOL_POLICIES,
|
||||
SETTINGS_KEY_TOOL_POLICIES_BY_ROLE,
|
||||
load_tool_policies_dict_for_role,
|
||||
)
|
||||
|
||||
payload = payload or {}
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
role = str(payload.get("role") or "").strip().lower()
|
||||
pol_in = payload.get("policies")
|
||||
if not isinstance(pol_in, dict):
|
||||
raise HTTPException(status_code=400, detail="policies must be an object")
|
||||
merged = dict(load_tool_policies_dict_for_role(store, role=role or None))
|
||||
|
||||
def _coerce_lv(v: Any) -> int | None:
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if n == 9999:
|
||||
return 9999
|
||||
if n <= 0:
|
||||
return 0
|
||||
return min(n, 9998)
|
||||
|
||||
clears = payload.get("clears")
|
||||
if isinstance(clears, list):
|
||||
for w in clears:
|
||||
wn = str(w or "").strip()
|
||||
if wn.startswith("mcp__"):
|
||||
merged.pop(wn, None)
|
||||
for k, v in pol_in.items():
|
||||
wn = str(k or "").strip()
|
||||
if not wn.startswith("mcp__"):
|
||||
continue
|
||||
co = _coerce_lv(v)
|
||||
if co is None:
|
||||
continue
|
||||
merged[wn] = co
|
||||
if not role:
|
||||
store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False))
|
||||
target_id = SETTINGS_KEY_TOOL_POLICIES
|
||||
else:
|
||||
raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}"
|
||||
try:
|
||||
outer = json.loads(raw)
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
outer = {}
|
||||
outer[role] = merged
|
||||
store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False))
|
||||
target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}"
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="mcp_tool_wire_policies_update",
|
||||
target_type="app_setting",
|
||||
target_id=target_id,
|
||||
status="ok",
|
||||
detail={"count": len(merged), "role": role},
|
||||
)
|
||||
return {"ok": True, "policies": merged, "role": role}
|
||||
|
||||
@router.post("/admin/api/mcp/tool-wire/policies/batch")
|
||||
def api_mcp_tool_wire_policies_batch(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
from svc.llm.tool_wire_policy import (
|
||||
SETTINGS_KEY_TOOL_POLICIES,
|
||||
SETTINGS_KEY_TOOL_POLICIES_BY_ROLE,
|
||||
load_tool_policies_dict_for_role,
|
||||
)
|
||||
|
||||
payload = payload or {}
|
||||
store = get_assistant_store()
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
try:
|
||||
lv = int(payload.get("level"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="level must be int")
|
||||
if lv != 9999 and (lv < 0 or lv > 9998):
|
||||
raise HTTPException(status_code=400, detail="invalid level")
|
||||
names = payload.get("wire_names")
|
||||
if not isinstance(names, list) or not names:
|
||||
raise HTTPException(status_code=400, detail="wire_names must be non-empty array")
|
||||
role = str(payload.get("role") or "").strip().lower()
|
||||
if role:
|
||||
valid_roles = set(_ordered_mcp_roles())
|
||||
if role not in valid_roles:
|
||||
raise HTTPException(status_code=400, detail="invalid_role")
|
||||
merged = dict(load_tool_policies_dict_for_role(store, role=role or None))
|
||||
for wn in names:
|
||||
s = str(wn or "").strip()
|
||||
if not s.startswith("mcp__"):
|
||||
continue
|
||||
if lv == 9999:
|
||||
merged[s] = 9999
|
||||
elif lv <= 0:
|
||||
merged[s] = 0
|
||||
else:
|
||||
merged[s] = min(lv, 9998)
|
||||
if not role:
|
||||
store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False))
|
||||
target_id = SETTINGS_KEY_TOOL_POLICIES
|
||||
else:
|
||||
raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}"
|
||||
try:
|
||||
outer = json.loads(raw)
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
outer = {}
|
||||
outer[role] = merged
|
||||
store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False))
|
||||
target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}"
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="mcp_tool_wire_policies_batch",
|
||||
target_type="app_setting",
|
||||
target_id=target_id,
|
||||
status="ok",
|
||||
detail={"level": lv, "n": len(names), "role": role},
|
||||
)
|
||||
return {"ok": True, "policies": merged, "role": role}
|
||||
|
||||
@router.get("/admin/api/mcp/market/search")
|
||||
def api_mcp_market_search(
|
||||
q: str = Query(default=""),
|
||||
|
|
|
|||
|
|
@ -4873,17 +4873,6 @@ function pluginsPagerBar(totalHolder, pageRef, onRepaint) {
|
|||
return { wrap, sync };
|
||||
}
|
||||
|
||||
/** 与后端 `load_tool_policies_dict` 一致:0;1–9998;≥9999 → 9999 */
|
||||
function normalizeWirePolicyLevel(raw) {
|
||||
const s = String(raw ?? "").trim();
|
||||
if (s === "") return 0;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
if (n >= 9999) return 9999;
|
||||
if (n <= 0) return 0;
|
||||
return Math.min(Math.trunc(n), 9998);
|
||||
}
|
||||
|
||||
async function renderPlugins() {
|
||||
const p = await apiGet("/admin/api/plugins");
|
||||
let toolPolicy = {
|
||||
|
|
@ -6181,580 +6170,6 @@ async function renderPlugins() {
|
|||
repaintUsageCalls();
|
||||
const usageSummaryPager = pluginsPagerBar(usageSummaryTotalHolder, usageSummaryPageRef, repaintUsageSummary);
|
||||
const usageCallsPager = pluginsPagerBar(usageCallsTotalHolder, usageCallsPageRef, repaintUsageCalls);
|
||||
let toolWire = { tools: [], config: {}, policies: {}, penalty_state: {}, role: "" };
|
||||
try {
|
||||
toolWire = await apiGet("/admin/api/mcp/tool-wire");
|
||||
} catch (_) {}
|
||||
const twc = toolWire.config || {};
|
||||
const wireTools = Array.isArray(toolWire.tools) ? toolWire.tools : [];
|
||||
const wireRoleMode = String(toolWire.role_mode || "restricted");
|
||||
const wireRoleSelect = el("select", { class: "input" }, [
|
||||
el("option", { value: "", text: "global(默认)" }),
|
||||
el("option", { value: "manager", text: "manager(全能者)" }),
|
||||
...availableSpecialists
|
||||
.filter((x) => String(x) !== "manager")
|
||||
.map((sp) => el("option", { value: String(sp), text: String(sp) })),
|
||||
]);
|
||||
wireRoleSelect.value = String(toolWire.role || "");
|
||||
const wireCfgStatus = el("div", { class: "muted", text: "" });
|
||||
const wirePolStatus = el("div", { class: "muted", text: "" });
|
||||
const wireRoleModeSelect = el("select", { class: "input" }, [
|
||||
el("option", { value: "restricted", text: "受限(启用惩罚机制)" }),
|
||||
el("option", { value: "unrestricted", text: "不受限(惩罚无效)" }),
|
||||
el("option", { value: "forbidden", text: "禁止(MCP 全禁)" }),
|
||||
]);
|
||||
wireRoleModeSelect.value = wireRoleMode;
|
||||
const saveWireRoleModeBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "保存 role 模式",
|
||||
onclick: async () => {
|
||||
const role = String(wireRoleSelect.value || "").trim();
|
||||
if (!role) {
|
||||
wireCfgStatus.textContent = "[role-mode] 请选择具体 role(非 global)";
|
||||
return;
|
||||
}
|
||||
const r = await apiPost("/admin/api/mcp/tool-wire/role-mode", {
|
||||
role,
|
||||
mode: String(wireRoleModeSelect.value || "restricted"),
|
||||
});
|
||||
wireCfgStatus.textContent = `[role-mode] ` + JSON.stringify(r);
|
||||
markPrewarmReminder("tool_wire_role_mode_changed");
|
||||
router();
|
||||
},
|
||||
});
|
||||
const applyWireRoleSelectorState = () => {
|
||||
const isGlobal = !String(wireRoleSelect.value || "").trim();
|
||||
saveWireRoleModeBtn.disabled = isGlobal;
|
||||
wireRoleModeSelect.disabled = isGlobal;
|
||||
if (isGlobal) {
|
||||
wireCfgStatus.textContent = "[role-mode] global 不支持 role 模式设置,请选择具体 role。";
|
||||
}
|
||||
};
|
||||
const inpWirePolicy = el("select", { class: "input" }, [
|
||||
el("option", { value: "inherit", text: "inherit(随 URL;DashScope 默认开)" }),
|
||||
el("option", { value: "always", text: "always(不按 URL,始终启用分层)" }),
|
||||
el("option", { value: "never", text: "never(关分层;9999 仍过滤)" }),
|
||||
]);
|
||||
inpWirePolicy.value = String(twc.wire_policy || "inherit");
|
||||
const inpTopN = el("input", { class: "input", type: "number", min: "3", max: "80", value: String(twc.top_n_full ?? 20) });
|
||||
const inpStaleH = el("input", { class: "input", type: "number", step: "0.25", value: String(twc.stale_hours ?? 3) });
|
||||
const inpPenMin = el("input", { class: "input", type: "number", value: String(twc.penalty_minutes ?? 30) });
|
||||
const inpMedS = el("input", { class: "input", type: "number", value: String(twc.medium_rank_start ?? 21) });
|
||||
const inpMedE = el("input", { class: "input", type: "number", value: String(twc.medium_rank_end ?? 50) });
|
||||
const inpMedDesc = el("input", { class: "input", type: "number", value: String(twc.medium_desc_chars ?? 520) });
|
||||
const inpMinCap = el("input", { class: "input", type: "number", value: String(twc.minimal_desc_cap ?? 80) });
|
||||
const inpPenaltyEnabled = el("input", { type: "checkbox" });
|
||||
inpPenaltyEnabled.checked = !Boolean(twc.penalty_disable);
|
||||
const saveWireCfgBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "保存全局参数",
|
||||
onclick: async () => {
|
||||
const r = await apiPost("/admin/api/mcp/tool-wire/config", {
|
||||
wire_policy: inpWirePolicy.value,
|
||||
top_n_full: Number(inpTopN.value),
|
||||
stale_hours: Number(inpStaleH.value),
|
||||
penalty_minutes: Number(inpPenMin.value),
|
||||
medium_rank_start: Number(inpMedS.value),
|
||||
medium_rank_end: Number(inpMedE.value),
|
||||
medium_desc_chars: Number(inpMedDesc.value),
|
||||
minimal_desc_cap: Number(inpMinCap.value),
|
||||
penalty_disable: !Boolean(inpPenaltyEnabled.checked),
|
||||
});
|
||||
wireCfgStatus.textContent = JSON.stringify(r);
|
||||
markPrewarmReminder("tool_wire_config_changed");
|
||||
router();
|
||||
},
|
||||
});
|
||||
const resetPenaltyStateBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "重置惩罚状态",
|
||||
onclick: async () => {
|
||||
if (!window.confirm("确认重置当前 MCP 工具惩罚状态?该操作会立即清空 penalty state。")) return;
|
||||
const qs = wireRoleSelect.value ? ("?role=" + encodeURIComponent(wireRoleSelect.value)) : "";
|
||||
const r = await apiPost("/admin/api/mcp/tool-wire/penalty/reset" + qs, {});
|
||||
wireCfgStatus.textContent = JSON.stringify(r);
|
||||
markPrewarmReminder("tool_wire_penalty_reset");
|
||||
router();
|
||||
},
|
||||
});
|
||||
const draftPolicies = {};
|
||||
wireTools.forEach((t) => {
|
||||
if (t.policy_in_db) draftPolicies[t.wire_name] = normalizeWirePolicyLevel(t.policy_level);
|
||||
else draftPolicies[t.wire_name] = null;
|
||||
});
|
||||
const wireToolBody = el("tbody");
|
||||
const wireToolsPageRef = { value: 1 };
|
||||
const wireToolsTotalHolder = { value: wireTools.length };
|
||||
const wireCheckedSet = new Set();
|
||||
const wireSubMatch = (a, pat) => {
|
||||
const p = String(pat || "").trim();
|
||||
if (!p) return true;
|
||||
return String(a ?? "").toLowerCase().includes(p.toLowerCase());
|
||||
};
|
||||
const wireFOnlyChecked = el("input", { type: "checkbox", title: "仅显示已勾选行" });
|
||||
const wireFServer = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" });
|
||||
const wireFTool = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" });
|
||||
const wireFWire = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" });
|
||||
const wireFExpert = el("input", { class: "input", placeholder: "专家含", style: "width:100%;min-width:64px;box-sizing:border-box;" });
|
||||
const wireFCountMin = el("input", { class: "input", type: "number", placeholder: "≥", style: "width:100%;box-sizing:border-box;" });
|
||||
const wireFCountMax = el("input", { class: "input", type: "number", placeholder: "≤", style: "width:100%;box-sizing:border-box;" });
|
||||
const wireFLastTs = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" });
|
||||
const wireFPenalty = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" });
|
||||
const wireFLevelMin = el("input", { class: "input", type: "number", placeholder: "等级≥", style: "width:100%;box-sizing:border-box;" });
|
||||
const wireFLevelMax = el("input", { class: "input", type: "number", placeholder: "等级≤", style: "width:100%;box-sizing:border-box;" });
|
||||
const wireFilterCountLabel = el("span", { class: "muted", text: "" });
|
||||
const wireRoleModeHint = el("div", { class: "muted", text: "" });
|
||||
const wireRowMatchesFilters = (t) => {
|
||||
if (wireFOnlyChecked.checked && !wireCheckedSet.has(t.wire_name)) return false;
|
||||
if (!wireSubMatch(t.server_id, wireFServer.value)) return false;
|
||||
if (!wireSubMatch(t.mcp_tool_name, wireFTool.value)) return false;
|
||||
if (!wireSubMatch(t.wire_name, wireFWire.value)) return false;
|
||||
const ex = specialistsBoundToServer(t.server_id).join(", ");
|
||||
if (!wireSubMatch(ex, wireFExpert.value)) return false;
|
||||
const cnt = Number(t.count || 0);
|
||||
if (String(wireFCountMin.value).trim() && cnt < Number(wireFCountMin.value)) return false;
|
||||
if (String(wireFCountMax.value).trim() && cnt > Number(wireFCountMax.value)) return false;
|
||||
const ph = (t.penalty && t.penalty.unblock_hint) || "";
|
||||
if (!wireSubMatch(ph, wireFPenalty.value)) return false;
|
||||
if (!wireSubMatch(t.last_ts || "", wireFLastTs.value)) return false;
|
||||
const lvRaw = draftPolicies[t.wire_name];
|
||||
const lvNum = lvRaw === null || lvRaw === undefined ? null : Number(lvRaw);
|
||||
const minS = String(wireFLevelMin.value).trim();
|
||||
if (minS) {
|
||||
const m = Number(minS);
|
||||
if (Number.isFinite(m)) {
|
||||
if (lvNum === null || lvNum === undefined) {
|
||||
if (m > 0) return false;
|
||||
} else if (lvNum < m) return false;
|
||||
}
|
||||
}
|
||||
const maxS = String(wireFLevelMax.value).trim();
|
||||
if (maxS && lvNum !== null && lvNum !== undefined) {
|
||||
const m = Number(maxS);
|
||||
if (Number.isFinite(m) && lvNum > m) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const roleModeBadge = (modeRaw) => {
|
||||
const mode = String(modeRaw || "restricted");
|
||||
if (mode === "unrestricted") {
|
||||
return el("span", { class: "badge badge--ok", text: "unrestricted" });
|
||||
}
|
||||
if (mode === "forbidden") {
|
||||
return el("span", { class: "badge badge--bad", text: "forbidden" });
|
||||
}
|
||||
return el("span", { class: "badge badge--mode-restricted", text: "restricted" });
|
||||
};
|
||||
const getWireToolsFiltered = () => wireTools.filter((x) => wireRowMatchesFilters(x));
|
||||
const paintWireToolRows = () => {
|
||||
wireToolBody.innerHTML = "";
|
||||
if (!wireTools.length) {
|
||||
wireToolBody.appendChild(el("tr", {}, [el("td", { text: "暂无已缓存工具(对各 MCP 点 Sync Tools)", colspan: "9" })]));
|
||||
return;
|
||||
}
|
||||
const list = getWireToolsFiltered();
|
||||
if (!list.length) {
|
||||
wireToolBody.appendChild(el("tr", {}, [el("td", { text: "无匹配行(请调整筛选)", colspan: "9" })]));
|
||||
return;
|
||||
}
|
||||
const start = (wireToolsPageRef.value - 1) * PLUGINS_PAGE_SIZE;
|
||||
list.slice(start, start + PLUGINS_PAGE_SIZE).forEach((t) => {
|
||||
const rowCb = el("input", { type: "checkbox" });
|
||||
rowCb.checked = wireCheckedSet.has(t.wire_name);
|
||||
rowCb.addEventListener("change", () => {
|
||||
if (rowCb.checked) wireCheckedSet.add(t.wire_name);
|
||||
else wireCheckedSet.delete(t.wire_name);
|
||||
});
|
||||
const lv0 = draftPolicies[t.wire_name];
|
||||
const lvlSel = el(
|
||||
"select",
|
||||
{
|
||||
class: "input",
|
||||
title: "按 role:默认(继承全局惩罚)/ 不惩罚 / 永禁",
|
||||
style: "width:140px;max-width:100%;",
|
||||
"data-wire-level": "1",
|
||||
},
|
||||
[
|
||||
el("option", { value: "", text: "默认(继承)" }),
|
||||
el("option", { value: "0", text: "不惩罚(0)" }),
|
||||
el("option", { value: "9999", text: "永禁(9999)" }),
|
||||
],
|
||||
);
|
||||
lvlSel.value = lv0 === null || lv0 === undefined ? "" : String(normalizeWirePolicyLevel(lv0));
|
||||
const syncLevelFromSelect = () => {
|
||||
const v = String(lvlSel.value || "").trim();
|
||||
if (!v) {
|
||||
draftPolicies[t.wire_name] = null;
|
||||
lvlSel.value = "";
|
||||
return;
|
||||
}
|
||||
const n = normalizeWirePolicyLevel(v);
|
||||
draftPolicies[t.wire_name] = n;
|
||||
lvlSel.value = String(n);
|
||||
};
|
||||
lvlSel.addEventListener("change", syncLevelFromSelect);
|
||||
const ph = (t.penalty && t.penalty.unblock_hint) || "-";
|
||||
const exCell = specialistsBoundToServer(t.server_id).join(", ") || "—";
|
||||
wireToolBody.appendChild(
|
||||
el(
|
||||
"tr",
|
||||
{ "data-wire-name": t.wire_name },
|
||||
[
|
||||
el("td", {}, [rowCb]),
|
||||
tdCell(t.server_id, 20),
|
||||
tdCell(t.mcp_tool_name, 22),
|
||||
tdCell(t.wire_name, 32),
|
||||
tdCell(exCell, 20),
|
||||
tdCell(String(t.count || 0), 8),
|
||||
tdCell(String(t.last_ts || "-"), 22),
|
||||
el("td", {}, [roleModeBadge(wireRoleModeSelect.value || "restricted")]),
|
||||
el("td", {
|
||||
text: ph,
|
||||
title: ph,
|
||||
style: "max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
|
||||
}),
|
||||
el("td", {}, [lvlSel]),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
const applyWireRoleModeUiState = () => {
|
||||
const mode = String(wireRoleModeSelect.value || "restricted");
|
||||
const disabled = mode !== "restricted";
|
||||
if (mode === "unrestricted") {
|
||||
wireRoleModeHint.textContent = "当前 role 为不受限:单工具惩罚策略不生效。";
|
||||
} else if (mode === "forbidden") {
|
||||
wireRoleModeHint.textContent = "当前 role 为禁止:MCP 全禁,单工具惩罚策略不生效。";
|
||||
} else {
|
||||
wireRoleModeHint.textContent = "";
|
||||
}
|
||||
const staticControls = [
|
||||
wireFOnlyChecked,
|
||||
wireFServer,
|
||||
wireFTool,
|
||||
wireFWire,
|
||||
wireFExpert,
|
||||
wireFCountMin,
|
||||
wireFCountMax,
|
||||
wireFLastTs,
|
||||
wireFPenalty,
|
||||
wireFLevelMin,
|
||||
wireFLevelMax,
|
||||
clearWireFiltersBtn,
|
||||
bulkLvlInput,
|
||||
applyBulkWireBtn,
|
||||
saveWirePolBtn,
|
||||
];
|
||||
staticControls.forEach((node) => {
|
||||
if (node) node.disabled = disabled;
|
||||
});
|
||||
Array.from(wireToolBody.querySelectorAll("input,select,button")).forEach((el0) => {
|
||||
el0.disabled = disabled;
|
||||
});
|
||||
};
|
||||
const wireToolsPager = pluginsPagerBar(wireToolsTotalHolder, wireToolsPageRef, paintWireToolRows);
|
||||
const refreshWireToolsFiltered = () => {
|
||||
const list = getWireToolsFiltered();
|
||||
wireToolsTotalHolder.value = list.length;
|
||||
wireFilterCountLabel.textContent = `筛选 ${list.length} / 共 ${wireTools.length} 条`;
|
||||
const maxPage = Math.max(1, Math.ceil(list.length / PLUGINS_PAGE_SIZE) || 1);
|
||||
if (wireToolsPageRef.value > maxPage) wireToolsPageRef.value = maxPage;
|
||||
if (wireToolsPageRef.value < 1) wireToolsPageRef.value = 1;
|
||||
paintWireToolRows();
|
||||
wireToolsPager.sync();
|
||||
applyWireRoleModeUiState();
|
||||
};
|
||||
const onWireFilterChange = () => {
|
||||
wireToolsPageRef.value = 1;
|
||||
refreshWireToolsFiltered();
|
||||
};
|
||||
[
|
||||
wireFServer,
|
||||
wireFTool,
|
||||
wireFWire,
|
||||
wireFExpert,
|
||||
wireFCountMin,
|
||||
wireFCountMax,
|
||||
wireFLastTs,
|
||||
wireFPenalty,
|
||||
wireFLevelMin,
|
||||
wireFLevelMax,
|
||||
].forEach((inp) => inp.addEventListener("input", onWireFilterChange));
|
||||
wireFOnlyChecked.addEventListener("change", onWireFilterChange);
|
||||
wireRoleModeSelect.addEventListener("change", applyWireRoleModeUiState);
|
||||
wireRoleSelect.addEventListener("change", applyWireRoleSelectorState);
|
||||
const bulkLvlInput = el(
|
||||
"select",
|
||||
{ class: "input", title: "批量写入", style: "width:140px;" },
|
||||
[
|
||||
el("option", { value: "0", text: "不惩罚(0)" }),
|
||||
el("option", { value: "9999", text: "永禁(9999)" }),
|
||||
el("option", { value: "", text: "清空(继承)" }),
|
||||
],
|
||||
);
|
||||
bulkLvlInput.value = "0";
|
||||
const clearWireFiltersBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "清除筛选",
|
||||
onclick: () => {
|
||||
wireFServer.value = "";
|
||||
wireFTool.value = "";
|
||||
wireFWire.value = "";
|
||||
wireFExpert.value = "";
|
||||
wireFCountMin.value = "";
|
||||
wireFCountMax.value = "";
|
||||
wireFLastTs.value = "";
|
||||
wireFPenalty.value = "";
|
||||
wireFLevelMin.value = "";
|
||||
wireFLevelMax.value = "";
|
||||
wireFOnlyChecked.checked = false;
|
||||
onWireFilterChange();
|
||||
},
|
||||
});
|
||||
const applyBulkWireBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "批量应用到选中",
|
||||
onclick: async () => {
|
||||
const bulkRaw = String(bulkLvlInput.value ?? "").trim();
|
||||
const lv = bulkRaw === "" ? null : normalizeWirePolicyLevel(bulkRaw);
|
||||
const selectedWireNames = [];
|
||||
Array.from(wireToolBody.querySelectorAll("tr")).forEach((tr) => {
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (!cb || !cb.checked) return;
|
||||
const wn = tr.getAttribute("data-wire-name");
|
||||
const sel = tr.querySelector("[data-wire-level]");
|
||||
if (wn && sel) {
|
||||
selectedWireNames.push(String(wn));
|
||||
draftPolicies[wn] = lv;
|
||||
sel.value = lv === null ? "" : String(lv);
|
||||
}
|
||||
});
|
||||
if (selectedWireNames.length) {
|
||||
try {
|
||||
if (lv === null) {
|
||||
await apiPost("/admin/api/mcp/tool-wire/policies", {
|
||||
role: String(wireRoleSelect.value || ""),
|
||||
policies: {},
|
||||
clears: selectedWireNames,
|
||||
});
|
||||
} else {
|
||||
await apiPost("/admin/api/mcp/tool-wire/policies/batch", {
|
||||
role: String(wireRoleSelect.value || ""),
|
||||
level: Number(lv),
|
||||
wire_names: selectedWireNames,
|
||||
});
|
||||
}
|
||||
markPrewarmReminder("tool_wire_policies_batch_changed");
|
||||
} catch (err) {
|
||||
wirePolStatus.textContent = `[批量] 后端批量写入失败: ${String((err && err.message) || err)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
wirePolStatus.textContent = "[批量] 已写入后端并更新本地视图";
|
||||
},
|
||||
});
|
||||
const saveWirePolBtn = el("button", {
|
||||
class: "btn btn--primary",
|
||||
text: "保存工具策略",
|
||||
onclick: async () => {
|
||||
Array.from(wireToolBody.querySelectorAll("tr")).forEach((tr) => {
|
||||
const wn = tr.getAttribute("data-wire-name");
|
||||
const sel = tr.querySelector("[data-wire-level]");
|
||||
if (wn && sel) {
|
||||
const raw = String(sel.value ?? "").trim();
|
||||
draftPolicies[wn] = raw === "" ? null : normalizeWirePolicyLevel(raw);
|
||||
}
|
||||
});
|
||||
const pol = {};
|
||||
const clears = [];
|
||||
wireTools.forEach((t) => {
|
||||
const wn = t.wire_name;
|
||||
const v = draftPolicies[wn];
|
||||
if (v === null || v === undefined) clears.push(wn);
|
||||
else pol[wn] = v;
|
||||
});
|
||||
const r = await apiPost("/admin/api/mcp/tool-wire/policies", {
|
||||
role: String(wireRoleSelect.value || ""),
|
||||
policies: pol,
|
||||
clears,
|
||||
});
|
||||
wirePolStatus.textContent = JSON.stringify(r);
|
||||
markPrewarmReminder("tool_wire_policies_changed");
|
||||
router();
|
||||
},
|
||||
});
|
||||
refreshWireToolsFiltered();
|
||||
applyWireRoleSelectorState();
|
||||
applyWireRoleModeUiState();
|
||||
const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [
|
||||
el("div", { class: "muted", text: "Tool policy(并发 / 轮次 / MCP·插件开关)与 Python 工具插件表" }),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "Turn max tool workers (1-32)" }),
|
||||
turnMaxWorkersInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "Turn max tool rounds (1-300)" }),
|
||||
turnMaxRoundsInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "Turn max context messages (10-400)" }),
|
||||
turnMaxCtxInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "SSE queue maxsize (200-50000)" }),
|
||||
sseQueueMaxsizeInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "Tool log max chars (20000-2000000)" }),
|
||||
toolLogMaxCharsInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")])]),
|
||||
el("div", { class: "row" }, [el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")])]),
|
||||
el("div", { class: "row" }, [el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")])]),
|
||||
el("div", { class: "row" }, [el("label", { class: "kv" }, [toolContextTruncateCb, document.createTextNode(" Compress tool result in agent context (50 chars + hint)")])]),
|
||||
el("div", { class: "row" }, [el("label", { class: "kv" }, [chatShowTtftDebugCb, document.createTextNode(" Show TTFT debug timings in chat status")])]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }),
|
||||
toolLlmMessageMaxCharsInput,
|
||||
]),
|
||||
el("div", { class: "muted", text: "Set 0 to disable truncation. If some gateways return 400 for oversized tool messages, set back to 24000." }),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "MCP filesystem extra roots (| separated)" }),
|
||||
mcpFilesystemExtraRootsInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "MCP env allowlist (comma separated)" }),
|
||||
mcpEnvAllowlistInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "oclaw retryable error codes (comma separated)" }),
|
||||
oclawRetryableErrorCodesInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [el("label", { class: "kv" }, [oclawRetryCodesStrictModeCb, document.createTextNode(" Strict mode: reject unknown retry codes")])]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "WeCom longconn workers (1-8)" }),
|
||||
wecomLongconnWorkersInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "WeCom inbound queue maxsize (20-5000)" }),
|
||||
wecomLongconnInboundQueueInput,
|
||||
]),
|
||||
el("div", { class: "row" }, [saveToolPolicyBtn]),
|
||||
toolPolicyStatus,
|
||||
el("div", { class: "table-wrap" }, [
|
||||
el("table", { class: "table" }, [
|
||||
el("thead", {}, [el("tr", {}, [el("th", { text: t("table.name") }), el("th", { text: t("table.version") }), el("th", { text: t("table.entryPoint") }), el("th", { text: t("table.enabled") })])]),
|
||||
pluginTbody,
|
||||
]),
|
||||
]),
|
||||
pluginPager.wrap,
|
||||
]);
|
||||
const foldMcpMarket = pluginsFold("【2】MCP 市场 / 依赖 / Trending / 检索结果", [
|
||||
el("div", { class: "muted", text: failureText ? `Failure summary: ${failureText}` : "Failure summary: -" }),
|
||||
el("div", { class: "muted", text: "本地依赖检查" }),
|
||||
depWrap,
|
||||
el("div", { class: "row" }, [marketQ, marketBtn, marketRefreshBtn]),
|
||||
el("div", { class: "muted", text: "Trending" }),
|
||||
trendingWrap,
|
||||
el("div", { class: "muted", text: "Market search" }),
|
||||
marketWrap,
|
||||
]);
|
||||
const foldMcpInstall = pluginsFold("【3】MCP 安装(表单 / JSON / 运维)", [
|
||||
el("div", { class: "row" }, [sourceType, sourceRef, version]),
|
||||
el("div", { class: "row" }, [entryCmd, entryArgs, installBtn]),
|
||||
el("div", { class: "muted", text: "CLI direct install (paste one command)" }),
|
||||
el("div", { class: "row" }, [cliInstallInput, cliInstallBtn]),
|
||||
el("div", { class: "muted", text: "常用命令行安装示例(可先本机验证,再填上方表单)" }),
|
||||
el("pre", {
|
||||
class: "pre",
|
||||
text:
|
||||
`# npm 包(本地安装)
|
||||
npm install mcp-fetch-server
|
||||
|
||||
# 直接运行(推荐)
|
||||
npx -y mcp-fetch-server
|
||||
|
||||
# 全局安装后运行
|
||||
npm install -g mcp-fetch-server
|
||||
mcp-fetch-server
|
||||
|
||||
# Python 包示例
|
||||
pip install mcp-server-time
|
||||
python -m mcp_server_time
|
||||
|
||||
# Python(Git URL / VCS)示例:必须显式指定 entry module
|
||||
pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m code_sandbox_mcp`,
|
||||
}),
|
||||
el("div", { class: "muted", text: "JSON install (single object or array)" }),
|
||||
jsonInstallInput,
|
||||
el("div", { class: "row" }, [jsonInstallBtn]),
|
||||
el("div", { class: "row", style: "flex-wrap:wrap;align-items:center;gap:8px;" }, [
|
||||
checkUpdatesBtn,
|
||||
updateOutdatedBtn,
|
||||
checkAllBtn,
|
||||
e2eCheckBtn,
|
||||
updateAllBtn,
|
||||
repairWeakBtn,
|
||||
repairWeakScopeLabel,
|
||||
]),
|
||||
installStatus,
|
||||
preflightFixWrap,
|
||||
]);
|
||||
const mcpExportJsonBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "Export JSON (download)",
|
||||
title: "Download uninstall/reinstallable snapshot (same shape as “Install from JSON”)",
|
||||
onclick: async () => {
|
||||
let r;
|
||||
try {
|
||||
r = await apiGet("/admin/api/mcp/export");
|
||||
} catch (err) {
|
||||
installStatus.textContent = "[export] " + String((err && err.message) || err);
|
||||
return;
|
||||
}
|
||||
if (!r || r.ok !== true || !r.document) {
|
||||
installStatus.textContent = "[export] failed: " + JSON.stringify(r);
|
||||
return;
|
||||
}
|
||||
const text = JSON.stringify(r.document, null, 2) + "\n";
|
||||
const blob = new Blob([text], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "mcp_registry_migrated.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
installStatus.textContent =
|
||||
"[export] downloaded mcp_registry_migrated.json" + (r.local_path ? " ; on server: " + r.local_path : "");
|
||||
},
|
||||
});
|
||||
const foldMcpInstalled = pluginsFold(`【4】已安装 MCP 服务(${mcpServerList.length})`, [
|
||||
el("div", { class: "row", style: "align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;" }, [
|
||||
mcpExportJsonBtn,
|
||||
el("div", {
|
||||
class: "muted",
|
||||
text: "新安装/重装/卸载(删记录)成功后自动写入: src/_local/mcp_registry_migrated.json,便于换机迁移。",
|
||||
}),
|
||||
]),
|
||||
el("div", { class: "table-wrap" }, [
|
||||
el("table", { class: "table table--compact" }, [
|
||||
el("thead", {}, [el("tr", {}, [
|
||||
el("th", { text: "server_id" }),
|
||||
el("th", { text: "source" }),
|
||||
el("th", { text: "ref" }),
|
||||
el("th", { text: "version" }),
|
||||
el("th", { text: "entry" }),
|
||||
el("th", { text: "tools" }),
|
||||
el("th", { text: "update" }),
|
||||
el("th", { text: "enabled" }),
|
||||
el("th", { text: "health" }),
|
||||
el("th", { text: "actions" }),
|
||||
])]),
|
||||
mcpInstalledTbody,
|
||||
]),
|
||||
]),
|
||||
mcpInstalledPager.wrap,
|
||||
]);
|
||||
const foldMcpUsage = pluginsFold(
|
||||
`【5】MCP 用量(summary ${usageSummaryList.length} / calls ${usageCallsList.length})`,
|
||||
[
|
||||
|
|
@ -6789,109 +6204,7 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m
|
|||
usageCallsPager.wrap,
|
||||
],
|
||||
);
|
||||
const foldWireGlobal = pluginsFold("【6】线侧策略 — 全局参数", [
|
||||
el("div", {
|
||||
class: "muted",
|
||||
text: "按 mcp__server__tool;wire_policy=always 不依赖 base_url。",
|
||||
}),
|
||||
el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
|
||||
el("label", { text: "wire_policy" }),
|
||||
inpWirePolicy,
|
||||
el("label", { text: "Top N 全量" }),
|
||||
inpTopN,
|
||||
el("label", { text: "全局闲置(h)" }),
|
||||
inpStaleH,
|
||||
el("label", { text: "罚时长(min)" }),
|
||||
inpPenMin,
|
||||
]),
|
||||
el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
|
||||
el("label", { text: "medium rank" }),
|
||||
inpMedS,
|
||||
inpMedE,
|
||||
el("label", { text: "medium 描述上限" }),
|
||||
inpMedDesc,
|
||||
el("label", { text: "minimal 描述" }),
|
||||
inpMinCap,
|
||||
el("label", {}, [inpPenaltyEnabled, el("span", { text: "启用惩罚机制", style: "margin-left:6px;" })]),
|
||||
]),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: "role" }),
|
||||
wireRoleSelect,
|
||||
wireRoleModeSelect,
|
||||
saveWireRoleModeBtn,
|
||||
saveWireCfgBtn,
|
||||
resetPenaltyStateBtn,
|
||||
wireCfgStatus,
|
||||
]),
|
||||
]);
|
||||
const wireLevelHint = el("div", {
|
||||
class: "muted",
|
||||
style: "font-size:12px;line-height:1.45;margin-bottom:6px;",
|
||||
text:
|
||||
"留空=未配置(运行时走全局闲置惩罚与线侧分层/压缩)。0=该 role 下此工具不参与闲置惩罚。任意整数 1–9998:闲置与罚均为 N×10 分钟;≥9999 视为 9999 永久不上送。新安装 MCP 在 Sync Tools 后出现新行,默认留空即自动走全局,直至你在本页保存。",
|
||||
});
|
||||
const foldWireTools = pluginsFold(`【7】线侧策略 — 已安装工具(${wireTools.length})`, [
|
||||
wireLevelHint,
|
||||
wireRoleModeHint,
|
||||
el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
|
||||
wireFilterCountLabel,
|
||||
clearWireFiltersBtn,
|
||||
el("span", { class: "muted", text: "批量等级" }),
|
||||
bulkLvlInput,
|
||||
applyBulkWireBtn,
|
||||
saveWirePolBtn,
|
||||
wirePolStatus,
|
||||
]),
|
||||
el("div", { class: "table-wrap" }, [
|
||||
el("table", { class: "table table--compact" }, [
|
||||
el("thead", {}, [
|
||||
el("tr", {}, [
|
||||
el("th", { text: "选" }),
|
||||
el("th", { text: "server" }),
|
||||
el("th", { text: "tool" }),
|
||||
el("th", { text: "wire_name" }),
|
||||
el("th", { text: "专家(绑定推导)" }),
|
||||
el("th", { text: "count" }),
|
||||
el("th", { text: "last_ts" }),
|
||||
el("th", { text: "effective_mode" }),
|
||||
el("th", { text: "惩罚/解封" }),
|
||||
el("th", { text: "策略" }),
|
||||
]),
|
||||
el("tr", {}, [
|
||||
el("th", {}, [wireFOnlyChecked]),
|
||||
el("th", {}, [wireFServer]),
|
||||
el("th", {}, [wireFTool]),
|
||||
el("th", {}, [wireFWire]),
|
||||
el("th", {}, [wireFExpert]),
|
||||
el("th", {}, [
|
||||
el("div", { style: "display:flex;flex-direction:column;gap:4px;" }, [wireFCountMin, wireFCountMax]),
|
||||
]),
|
||||
el("th", {}, [wireFLastTs]),
|
||||
el("th", { text: "-" }),
|
||||
el("th", {}, [wireFPenalty]),
|
||||
el("th", {}, [
|
||||
el("div", { style: "display:flex;flex-direction:column;gap:4px;" }, [wireFLevelMin, wireFLevelMax]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
wireToolBody,
|
||||
]),
|
||||
]),
|
||||
wireToolsPager.wrap,
|
||||
]);
|
||||
wireRoleSelect.addEventListener("change", async () => {
|
||||
try {
|
||||
const qs = wireRoleSelect.value ? ("?role=" + encodeURIComponent(wireRoleSelect.value)) : "";
|
||||
toolWire = await apiGet("/admin/api/mcp/tool-wire" + qs);
|
||||
wireCfgStatus.textContent = `[role] switched to ${String(toolWire.role || "global")}`;
|
||||
wireRoleModeSelect.value = String(toolWire.role_mode || "restricted");
|
||||
// reload current page to rebuild wireTools + drafts cleanly
|
||||
router();
|
||||
} catch (err) {
|
||||
wireCfgStatus.textContent = `[role] load failed: ${String((err && err.message) || err)}`;
|
||||
}
|
||||
});
|
||||
const foldExpertBindingDash = pluginsFold("【8】专家 MCP 绑定看板(自动)", [
|
||||
const foldExpertBindingDash = pluginsFold("【6】专家 MCP 绑定看板(自动)", [
|
||||
el("div", {
|
||||
class: "muted",
|
||||
text: "按当前绑定草稿与已安装 MCP 的 tools 列表汇总;专家列表来自 SPECIALISTS 与绑定 mapping 键,随扩展自动增减。",
|
||||
|
|
@ -6907,7 +6220,7 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m
|
|||
]),
|
||||
]),
|
||||
]);
|
||||
const foldMcpBinding = pluginsFold("【9】MCP 专家绑定(编辑)", [
|
||||
const foldMcpBinding = pluginsFold("【7】MCP 专家绑定(编辑)", [
|
||||
el("div", { class: "muted", text: "Bind MCP servers to specialists (many-to-many)." }),
|
||||
el("div", { class: "row" }, [el("label", { text: "Specialist" }), specialistSelect, selectAllBindingBtn, clearBindingBtn, saveBindingBtn]),
|
||||
bindingListWrap,
|
||||
|
|
@ -6932,8 +6245,6 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m
|
|||
el("div", { id: "plugins-install" }, [foldMcpInstall]),
|
||||
el("div", { id: "plugins-instances" }, [foldMcpInstalled]),
|
||||
foldMcpUsage,
|
||||
foldWireGlobal,
|
||||
foldWireTools,
|
||||
foldExpertBindingDash,
|
||||
el("div", { id: "plugins-binding" }, [foldMcpBinding]),
|
||||
]),
|
||||
|
|
@ -9078,7 +8389,7 @@ async function renderSkills() {
|
|||
});
|
||||
const llmToolsBox = el("details", { style: "margin:10px 0 14px 0;" }, [
|
||||
el("summary", { text: "LLM tools preview (after wire policy)", style: "cursor:pointer;user-select:none;" }),
|
||||
el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Preview the final tools injected to the model for a role (internal + MCP + role_mode + permanent bans + wire policy tiers/penalty)." }),
|
||||
el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Preview the final tools injected to the model for a role (internal + MCP + role_mode + MCP specialist binding)." }),
|
||||
llmToolsStatus,
|
||||
el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center;" }, [
|
||||
el("label", { text: "Role" }),
|
||||
|
|
|
|||
|
|
@ -5,13 +5,7 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from svc.llm.tool_schema import default_max_openai_tools_json_bytes
|
||||
from svc.llm.tool_wire_policy import (
|
||||
load_merged_admin_config,
|
||||
load_role_mode_for_role,
|
||||
load_tool_policies_dict_for_role,
|
||||
prepare_openai_tools_for_llm_api,
|
||||
wire_graduation_effective,
|
||||
)
|
||||
from svc.llm.tool_wire_policy import prepare_openai_tools_for_llm_api
|
||||
from runtime.tools.base import ToolSpec
|
||||
from runtime.tools.catalog import _is_truthy
|
||||
from runtime.tools.expert_registry import materialize_tools_for_expert, preview_expert_tools
|
||||
|
|
@ -174,11 +168,7 @@ def build_llm_tools_plan(
|
|||
else:
|
||||
raw_openai_tools = list(raw_openai_tools_override)
|
||||
|
||||
admin_cfg = load_merged_admin_config(store)
|
||||
role_mode = load_role_mode_for_role(store, role=r)
|
||||
policies = load_tool_policies_dict_for_role(store, role=r)
|
||||
wire_effective = wire_graduation_effective(bu, admin_cfg) and role_mode == "restricted"
|
||||
|
||||
# Wire suppression removed: only schema complete + optional JSON size shrink.
|
||||
wired_openai_tools = prepare_openai_tools_for_llm_api(
|
||||
raw_openai_tools,
|
||||
base_url=bu,
|
||||
|
|
@ -195,7 +185,7 @@ def build_llm_tools_plan(
|
|||
added = sorted([n for n in wired_set if n and n not in raw_set])
|
||||
removed_mcp = [n for n in removed if n.startswith("mcp__")]
|
||||
|
||||
# changed = same tool name but payload differs
|
||||
# changed = same tool name but payload differs (e.g. shrink shortened descriptions)
|
||||
def _map_by_name(tools: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
m: dict[str, dict[str, Any]] = {}
|
||||
for ent in tools or []:
|
||||
|
|
@ -221,9 +211,9 @@ def build_llm_tools_plan(
|
|||
base_url=bu,
|
||||
max_json_bytes=cap,
|
||||
mcp_enabled=mcp_enabled,
|
||||
role_mode=str(role_mode or "restricted"),
|
||||
wire_policy_effective=bool(wire_effective),
|
||||
policy_keys=len(policies),
|
||||
role_mode="unrestricted",
|
||||
wire_policy_effective=False,
|
||||
policy_keys=0,
|
||||
public_risk_gate_allow_high=bool(diag_internal.get("public_risk_gate_allow_high")),
|
||||
public_blocked_high_risk_tools=list(diag_internal.get("public_blocked_high_risk_tools") or []),
|
||||
skipped_public=list(diag_internal.get("skipped_public") or []),
|
||||
|
|
|
|||
|
|
@ -1,401 +1,76 @@
|
|||
"""Graduated OpenAI ``tools[]`` shaping + usage-aware omission / per-tool admin policies.
|
||||
"""OpenAI ``tools[]`` wire prep (schema complete + optional size shrink).
|
||||
|
||||
Uses ``tool_log`` aggregates, ``app_setting`` for admin config / per-tool levels / penalty state.
|
||||
Wire payload differs from local :class:`~src.tools.base.ToolSpec`; validation still uses full schemas.
|
||||
MCP suppression / penalty / tier / role_mode wire policy was removed.
|
||||
Server visibility is controlled only by specialist↔MCP binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from svc.llm.tool_schema import MIN_OPENAI_FUNCTION_PARAMETERS, complete_openai_tools_wire_parameters
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from svc.llm.tool_schema import complete_openai_tools_wire_parameters
|
||||
|
||||
# Legacy setting keys kept so old admin/DB rows do not break readers.
|
||||
SETTINGS_KEY_PENALTY_STATE = "mcp_tool_wire_penalty_state"
|
||||
SETTINGS_KEY_TOOL_POLICIES = "mcp_tool_wire_tool_policies"
|
||||
SETTINGS_KEY_ADMIN_CONFIG = "mcp_tool_wire_admin_config"
|
||||
|
||||
# Role-scoped (per expert) overrides. Shape:
|
||||
# - policies: { "<role>": { "mcp__server__tool": <int level> } }
|
||||
# - penalty: { "<role>": { "mcp__server__tool": <penalty state dict> } }
|
||||
SETTINGS_KEY_PENALTY_STATE_BY_ROLE = "mcp_tool_wire_penalty_state_by_role"
|
||||
SETTINGS_KEY_TOOL_POLICIES_BY_ROLE = "mcp_tool_wire_tool_policies_by_role"
|
||||
SETTINGS_KEY_ROLE_MODE_BY_ROLE = "mcp_tool_wire_role_mode_by_role"
|
||||
|
||||
_MIN_PARAMETERS: dict[str, Any] = dict(MIN_OPENAI_FUNCTION_PARAMETERS)
|
||||
|
||||
|
||||
def _env_prefixed(name_suffix: str, default: str = "") -> str:
|
||||
raw = str(os.getenv(f"AIA_{name_suffix}") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
raw = str(os.getenv(f"OPS_{name_suffix}") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
return str(default).strip()
|
||||
|
||||
|
||||
def wire_policy_enabled(base_url: str | None) -> bool:
|
||||
raw = _env_prefixed("MCP_WIRE_USAGE_POLICY", "").lower()
|
||||
if raw in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if raw in ("0", "false", "no", "off"):
|
||||
def wire_policy_enabled(_base_url: str | None) -> bool:
|
||||
return False
|
||||
u = (base_url or "").lower()
|
||||
return "dashscope.aliyuncs.com" in u
|
||||
|
||||
|
||||
def penalty_disabled() -> bool:
|
||||
return _env_prefixed("MCP_WIRE_PENALTY_DISABLE", "").lower() in ("1", "true", "yes", "on")
|
||||
def wire_graduation_effective(_base_url: str | None, _admin: dict[str, Any] | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _admin_defaults_from_env() -> dict[str, Any]:
|
||||
def load_merged_admin_config(_store: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"wire_policy": "inherit",
|
||||
"top_n_full": int(_env_prefixed("MCP_WIRE_TOP_N_FULL", "20") or "20"),
|
||||
"stale_hours": float(_env_prefixed("MCP_WIRE_STALE_HOURS", "3") or "3"),
|
||||
"penalty_minutes": float(_env_prefixed("MCP_WIRE_PENALTY_MINUTES", "30") or "30"),
|
||||
"medium_rank_start": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_START", "21") or "21"),
|
||||
"medium_rank_end": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_END", "50") or "50"),
|
||||
"medium_desc_chars": int(_env_prefixed("MCP_WIRE_MEDIUM_DESC_CHARS", "520") or "520"),
|
||||
"minimal_desc_cap": int(_env_prefixed("MCP_WIRE_MINIMAL_DESC_CAP", "80") or "80"),
|
||||
"penalty_disable": False,
|
||||
"wire_policy": "never",
|
||||
"top_n_full": 20,
|
||||
"stale_hours": 3.0,
|
||||
"penalty_minutes": 30.0,
|
||||
"medium_rank_start": 21,
|
||||
"medium_rank_end": 50,
|
||||
"medium_desc_chars": 520,
|
||||
"minimal_desc_cap": 80,
|
||||
"penalty_disable": True,
|
||||
}
|
||||
|
||||
|
||||
def load_merged_admin_config(store: Any) -> dict[str, Any]:
|
||||
out = _admin_defaults_from_env()
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG)
|
||||
if not raw:
|
||||
return out
|
||||
d = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(d, dict):
|
||||
for k, v in d.items():
|
||||
if k in out or k == "wire_policy":
|
||||
out[k] = v
|
||||
except Exception:
|
||||
pass
|
||||
pd = out.get("penalty_disable")
|
||||
if isinstance(pd, str):
|
||||
out["penalty_disable"] = pd.strip().lower() in ("1", "true", "yes", "on")
|
||||
return out
|
||||
|
||||
|
||||
def load_tool_policies_dict(store: Any) -> dict[str, int]:
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES) or "{}"
|
||||
d = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
def load_tool_policies_dict(_store: Any) -> dict[str, int]:
|
||||
return {}
|
||||
if not isinstance(d, dict):
|
||||
|
||||
|
||||
def load_tool_policies_dict_for_role(_store: Any, *, role: str | None) -> dict[str, int]:
|
||||
del role
|
||||
return {}
|
||||
out: dict[str, int] = {}
|
||||
for k, v in d.items():
|
||||
nm = str(k or "").strip()
|
||||
if not nm.startswith("mcp__"):
|
||||
continue
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if n == 9999:
|
||||
out[nm] = 9999
|
||||
elif n <= 0:
|
||||
out[nm] = 0
|
||||
else:
|
||||
out[nm] = min(n, 9998)
|
||||
return out
|
||||
|
||||
|
||||
def load_tool_policies_dict_for_role(store: Any, *, role: str | None) -> dict[str, int]:
|
||||
"""Role-scoped policies; falls back to global if absent/unparseable."""
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
return load_tool_policies_dict(store)
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "{}"
|
||||
outer = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
return load_tool_policies_dict(store)
|
||||
inner = outer.get(r)
|
||||
if not isinstance(inner, dict):
|
||||
return load_tool_policies_dict(store)
|
||||
# Reuse same coercion rules as global loader.
|
||||
out: dict[str, int] = {}
|
||||
for k, v in inner.items():
|
||||
nm = str(k or "").strip()
|
||||
if not nm.startswith("mcp__"):
|
||||
continue
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if n == 9999:
|
||||
out[nm] = 9999
|
||||
elif n <= 0:
|
||||
out[nm] = 0
|
||||
else:
|
||||
out[nm] = min(n, 9998)
|
||||
return out
|
||||
def load_penalty_state_for_role(_store: Any, *, role: str | None) -> dict[str, Any]:
|
||||
del role
|
||||
return {}
|
||||
|
||||
|
||||
def load_penalty_state_for_role(store: Any, *, role: str | None) -> dict[str, Any]:
|
||||
"""Role-scoped penalty state; falls back to global if absent/unparseable."""
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
raw_pen = store.get_setting(SETTINGS_KEY_PENALTY_STATE) or "{}"
|
||||
try:
|
||||
pen = json.loads(raw_pen) if isinstance(raw_pen, str) else {}
|
||||
except Exception:
|
||||
pen = {}
|
||||
if not isinstance(pen, dict):
|
||||
pen = {}
|
||||
return migrate_legacy_penalty_store(pen)
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}"
|
||||
outer = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
return load_penalty_state_for_role(store, role=None)
|
||||
inner = outer.get(r)
|
||||
if not isinstance(inner, dict):
|
||||
return load_penalty_state_for_role(store, role=None)
|
||||
return migrate_legacy_penalty_store(inner)
|
||||
def load_role_mode_for_role(_store: Any, *, role: str | None) -> str:
|
||||
del role
|
||||
return "unrestricted"
|
||||
|
||||
|
||||
def _persist_penalty_state_for_role(store: Any, *, role: str | None, penalty_state: dict[str, Any]) -> None:
|
||||
r = str(role or "").strip().lower()
|
||||
clean: dict[str, Any] = {}
|
||||
for k, v in (penalty_state or {}).items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
clean[str(k)] = v
|
||||
if not r:
|
||||
store.set_setting(SETTINGS_KEY_PENALTY_STATE, json.dumps(clean, ensure_ascii=False))
|
||||
return
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}"
|
||||
outer = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
outer = {}
|
||||
outer[r] = clean
|
||||
store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(outer, ensure_ascii=False))
|
||||
def filter_permanent_ban_mcp_tools(tools: list[dict[str, Any]], _policies: dict[str, int] | None = None) -> list[dict[str, Any]]:
|
||||
return list(tools or [])
|
||||
|
||||
|
||||
def wire_graduation_effective(base_url: str | None, admin: dict[str, Any]) -> bool:
|
||||
mode = str(admin.get("wire_policy") or "inherit").strip().lower()
|
||||
if mode in ("always", "on", "true", "1", "yes"):
|
||||
return True
|
||||
if mode in ("never", "off", "false", "0", "no"):
|
||||
return False
|
||||
return wire_policy_enabled(base_url)
|
||||
|
||||
|
||||
def penalty_effective_disabled(admin: dict[str, Any]) -> bool:
|
||||
if bool(admin.get("penalty_disable")):
|
||||
return True
|
||||
return penalty_disabled()
|
||||
|
||||
|
||||
def migrate_legacy_penalty_store(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Older rows used ``omit_until`` without ``phase``; add ``kind`` for global tier."""
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in (raw or {}).items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
v2 = dict(v)
|
||||
if str(v2.get("phase") or "") in ("active", "done"):
|
||||
if "kind" not in v2:
|
||||
v2["kind"] = "d"
|
||||
out[str(k)] = v2
|
||||
continue
|
||||
ou_s = str(v2.get("omit_until") or "")
|
||||
ws = str(v2.get("wave_ts") or "")
|
||||
ou = _parse_iso(ou_s)
|
||||
if ou_s and ou:
|
||||
if _utc_now() < ou:
|
||||
v2 = {"phase": "active", "omit_until": ou_s, "wave_ts": ws, "kind": v2.get("kind") or "d"}
|
||||
else:
|
||||
v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"}
|
||||
elif ws:
|
||||
v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"}
|
||||
else:
|
||||
continue
|
||||
out[str(k)] = v2
|
||||
return out
|
||||
|
||||
|
||||
def _parse_iso(ts: str) -> datetime | None:
|
||||
s = str(ts or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
def migrate_legacy_penalty_store(_store: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _wire_json_size(tools: list[dict[str, Any]]) -> int:
|
||||
try:
|
||||
return len(json.dumps(tools, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _strip_nested_descriptions(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
if str(k) == "description":
|
||||
continue
|
||||
out[str(k)] = _strip_nested_descriptions(v)
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [_strip_nested_descriptions(x) for x in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _tier_medium(fn: dict[str, Any], *, cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
mid_desc_len = int(cfg.get("medium_desc_chars") or 520)
|
||||
mid_desc_len = max(80, min(mid_desc_len, 4000))
|
||||
desc = str(fn.get("description") or "")[:mid_desc_len]
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
params_out = dict(_strip_nested_descriptions(params))
|
||||
else:
|
||||
params_out = dict(_MIN_PARAMETERS)
|
||||
return {"name": fn.get("name"), "description": desc, "parameters": params_out}
|
||||
|
||||
|
||||
def _tier_minimal(fn: dict[str, Any], *, desc_cap: int) -> dict[str, Any]:
|
||||
desc = str(fn.get("description") or "")[: max(0, desc_cap)]
|
||||
return {"name": fn.get("name"), "description": desc, "parameters": dict(_MIN_PARAMETERS)}
|
||||
|
||||
|
||||
def _clone_tool_entry(t: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(json.dumps(t, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
return dict(t)
|
||||
|
||||
|
||||
def _is_builtin_tool_name(name: str) -> bool:
|
||||
return not str(name).startswith("mcp__")
|
||||
|
||||
|
||||
def _normalize_role_mode(v: Any) -> str:
|
||||
s = str(v or "").strip().lower()
|
||||
if s in {"unrestricted", "forbidden", "restricted"}:
|
||||
return s
|
||||
return "restricted"
|
||||
|
||||
|
||||
def load_role_mode_for_role(store: Any, *, role: str | None) -> str:
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
return "restricted"
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "{}"
|
||||
obj = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
obj = {}
|
||||
if not isinstance(obj, dict):
|
||||
return "restricted"
|
||||
return _normalize_role_mode(obj.get(r))
|
||||
|
||||
|
||||
def _is_stale_hours(last_ts_iso: str | None, stale_hours: float) -> bool:
|
||||
if not last_ts_iso:
|
||||
return False
|
||||
dt = _parse_iso(last_ts_iso)
|
||||
if dt is None:
|
||||
return False
|
||||
return (_utc_now() - dt).total_seconds() > stale_hours * 3600.0
|
||||
|
||||
|
||||
def _idle_minutes_exceeded(last_ts_iso: str | None, idle_minutes: float) -> bool:
|
||||
if not last_ts_iso:
|
||||
return False
|
||||
dt = _parse_iso(last_ts_iso)
|
||||
if dt is None:
|
||||
return False
|
||||
return (_utc_now() - dt).total_seconds() > idle_minutes * 60.0
|
||||
|
||||
|
||||
def _wave_penalty_omit(
|
||||
name: str,
|
||||
last_ts: str | None,
|
||||
idle_hit: bool,
|
||||
penalty_minutes: float,
|
||||
st_nm: Any,
|
||||
out_penalty_updates: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
) -> bool:
|
||||
pw = last_ts or ""
|
||||
mins = max(1.0, min(float(penalty_minutes), 24 * 60))
|
||||
if not idle_hit:
|
||||
return False
|
||||
prev = out_penalty_updates.get(name)
|
||||
if isinstance(prev, dict) and str(prev.get("kind") or "") != str(kind):
|
||||
out_penalty_updates.pop(name, None)
|
||||
st_nm = None
|
||||
else:
|
||||
st_nm = prev
|
||||
|
||||
omit = False
|
||||
if isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "active":
|
||||
ou = _parse_iso(str(st_nm.get("omit_until") or ""))
|
||||
if ou and _utc_now() < ou:
|
||||
omit = True
|
||||
else:
|
||||
out_penalty_updates[name] = {"phase": "done", "wave_ts": pw, "kind": str(kind)}
|
||||
elif isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "done" and str(st_nm.get("wave_ts") or "") == pw:
|
||||
pass
|
||||
else:
|
||||
until = (_utc_now() + timedelta(minutes=mins)).isoformat()
|
||||
out_penalty_updates[name] = {"phase": "active", "omit_until": until, "wave_ts": pw, "kind": str(kind)}
|
||||
omit = True
|
||||
return omit
|
||||
|
||||
|
||||
def filter_permanent_ban_mcp_tools(tools: list[dict[str, Any]], policies: dict[str, int]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in tools:
|
||||
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
|
||||
out.append(entry)
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
out.append(entry)
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name or _is_builtin_tool_name(name):
|
||||
out.append(entry)
|
||||
continue
|
||||
if policies.get(name) == 9999:
|
||||
continue
|
||||
out.append(entry)
|
||||
return out
|
||||
def penalty_row_status(_row: dict[str, Any] | None, *, now: Any = None) -> str:
|
||||
del now
|
||||
return "ok"
|
||||
|
||||
|
||||
def prepare_openai_tools_for_llm_api(
|
||||
|
|
@ -406,364 +81,32 @@ def prepare_openai_tools_for_llm_api(
|
|||
store: Any | None = None,
|
||||
role: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build wire ``tools[]``: optional graduated policy + byte cap."""
|
||||
|
||||
def _fallback_shrink(raw: list[dict[str, Any]], cap: int) -> list[dict[str, Any]]:
|
||||
from svc.llm.tool_schema import shrink_openai_tools_payload_for_api
|
||||
|
||||
return shrink_openai_tools_payload_for_api(raw, max_json_bytes=cap)
|
||||
|
||||
"""Complete tool schemas and optionally shrink payload size. No MCP suppression."""
|
||||
del base_url, store, role
|
||||
if not tools:
|
||||
return tools
|
||||
|
||||
tools = complete_openai_tools_wire_parameters(tools)
|
||||
|
||||
def _finalize(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Re-apply completion after tier/shrink/squeeze (those paths emit fresh ``parameters`` dicts)."""
|
||||
return complete_openai_tools_wire_parameters(result)
|
||||
|
||||
policies: dict[str, int] = {}
|
||||
admin: dict[str, Any] = _admin_defaults_from_env()
|
||||
role_mode = "restricted"
|
||||
try:
|
||||
if store is None:
|
||||
from svc.config.paths import db_path
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
store = get_assistant_store()
|
||||
# Role-scoped policies if configured; otherwise fall back to global.
|
||||
policies = load_tool_policies_dict_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
admin = load_merged_admin_config(store)
|
||||
role_mode = load_role_mode_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
except Exception as exc:
|
||||
logger.warning("tool_wire_policy: load admin/policies (%s)", exc)
|
||||
|
||||
def _filter_mcp_out(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
|
||||
out.append(entry)
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
out.append(entry)
|
||||
continue
|
||||
nm = str(fn.get("name") or "").strip()
|
||||
if nm.startswith("mcp__"):
|
||||
continue
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
tools_pass1 = filter_permanent_ban_mcp_tools(tools, policies)
|
||||
|
||||
if role_mode == "forbidden":
|
||||
out = _filter_mcp_out(tools)
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _finalize(_fallback_shrink(out, max_json_bytes))
|
||||
return _finalize(out)
|
||||
if role_mode == "unrestricted":
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _finalize(_fallback_shrink(tools_pass1, max_json_bytes))
|
||||
return _finalize(tools_pass1)
|
||||
|
||||
if not wire_graduation_effective(base_url, admin):
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _finalize(_fallback_shrink(tools_pass1, max_json_bytes))
|
||||
return _finalize(tools_pass1)
|
||||
|
||||
top_n = int(admin.get("top_n_full") or 20)
|
||||
top_n = max(3, min(top_n, 80))
|
||||
stale_h = float(admin.get("stale_hours") or 3)
|
||||
stale_h = max(0.25, min(stale_h, 720.0))
|
||||
penalty_min_global = float(admin.get("penalty_minutes") or 30)
|
||||
penalty_min_global = max(1.0, min(penalty_min_global, 24 * 60))
|
||||
medium_floor = int(admin.get("medium_rank_start") or 21)
|
||||
medium_ceiling = int(admin.get("medium_rank_end") or 50)
|
||||
if medium_floor > medium_ceiling:
|
||||
medium_floor, medium_ceiling = medium_ceiling, medium_floor
|
||||
|
||||
usage_map: dict[str, dict[str, Any]] = {}
|
||||
penalty_state: dict[str, Any] = {}
|
||||
try:
|
||||
usage_map = store.list_mcp_tool_aggregate_usage()
|
||||
penalty_state = load_penalty_state_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
except Exception as exc:
|
||||
logger.warning("tool_wire_policy: usage store unavailable (%s); fallback shrink only", exc)
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _finalize(_fallback_shrink(tools_pass1, max_json_bytes))
|
||||
return _finalize(tools_pass1)
|
||||
|
||||
ranked = sorted(
|
||||
usage_map.items(),
|
||||
key=lambda kv: (-int(kv[1].get("count") or 0), str(kv[1].get("last_ts") or "")),
|
||||
)
|
||||
rank_by_name = {nm: idx + 1 for idx, (nm, _) in enumerate(ranked)}
|
||||
top_full = {nm for nm, _ in ranked[:top_n]}
|
||||
|
||||
out_penalty_updates: dict[str, Any] = dict(penalty_state)
|
||||
omit_set: set[str] = set()
|
||||
|
||||
for nm in list(out_penalty_updates.keys()):
|
||||
meta = usage_map.get(nm)
|
||||
cur_ts = str(meta.get("last_ts") or "") if meta else ""
|
||||
st = out_penalty_updates.get(nm)
|
||||
if not isinstance(st, dict):
|
||||
continue
|
||||
w_ts = str(st.get("wave_ts") or "")
|
||||
if w_ts and cur_ts and w_ts != cur_ts:
|
||||
out_penalty_updates.pop(nm, None)
|
||||
|
||||
pen_glob = penalty_effective_disabled(admin)
|
||||
built: list[dict[str, Any]] = []
|
||||
for entry in tools_pass1:
|
||||
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
|
||||
built.append(entry)
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
built.append(entry)
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
built.append(entry)
|
||||
continue
|
||||
|
||||
if _is_builtin_tool_name(name):
|
||||
built.append(_clone_tool_entry(entry))
|
||||
continue
|
||||
|
||||
usage = usage_map.get(name) or {}
|
||||
last_ts = str(usage.get("last_ts") or "") or None
|
||||
rk = rank_by_name.get(name)
|
||||
|
||||
policy_lv = policies.get(name)
|
||||
st_nm = out_penalty_updates.get(name)
|
||||
|
||||
# policy_lv semantics (punishment only):
|
||||
# - None: inherit global graduation + idle penalty.
|
||||
# - 0: exempt this tool from idle penalty (but still participates in graduation tiers).
|
||||
# - 1..9998: per-tool idle/penalty tuning (N * 10 minutes) + still participates in graduation tiers.
|
||||
# - 9999: permanent ban (filtered earlier in filter_permanent_ban_mcp_tools).
|
||||
omit = False
|
||||
if policy_lv == 0:
|
||||
omit = False
|
||||
elif policy_lv is not None and policy_lv >= 1:
|
||||
idle_m = float(policy_lv) * 10.0
|
||||
pen_m = float(policy_lv) * 10.0
|
||||
idle_hit = _idle_minutes_exceeded(last_ts, idle_m)
|
||||
kind = f"L{int(policy_lv)}"
|
||||
if not pen_glob:
|
||||
omit = _wave_penalty_omit(
|
||||
name, last_ts, idle_hit, pen_m, st_nm, out_penalty_updates, kind=kind
|
||||
)
|
||||
else:
|
||||
idle_hit = _is_stale_hours(last_ts, stale_h)
|
||||
exempt = name in top_full
|
||||
if not pen_glob and idle_hit and not exempt:
|
||||
omit = _wave_penalty_omit(
|
||||
name,
|
||||
last_ts,
|
||||
idle_hit,
|
||||
penalty_min_global,
|
||||
st_nm,
|
||||
out_penalty_updates,
|
||||
kind="d",
|
||||
)
|
||||
|
||||
if omit:
|
||||
omit_set.add(name)
|
||||
continue
|
||||
|
||||
tier = "minimal"
|
||||
if name in top_full:
|
||||
tier = "full"
|
||||
elif rk is not None and medium_floor <= rk <= medium_ceiling:
|
||||
tier = "medium"
|
||||
else:
|
||||
tier = "minimal"
|
||||
|
||||
if tier == "full":
|
||||
built.append(_clone_tool_entry(entry))
|
||||
elif tier == "medium":
|
||||
mf = _tier_medium(fn, cfg=admin)
|
||||
built.append({"type": "function", "function": mf})
|
||||
else:
|
||||
cap0 = int(admin.get("minimal_desc_cap") or 80)
|
||||
cap0 = max(0, min(cap0, 2000))
|
||||
mf = _tier_minimal(fn, desc_cap=cap0)
|
||||
built.append({"type": "function", "function": mf})
|
||||
|
||||
try:
|
||||
_persist_penalty_state_for_role(
|
||||
store,
|
||||
role=str(role or "").strip().lower() or None,
|
||||
penalty_state=out_penalty_updates,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("tool_wire_policy: persist penalty state failed: %s", exc)
|
||||
|
||||
if omit_set:
|
||||
logger.info(
|
||||
"tool_wire_policy: omitted %d MCP tools during penalty window: %s",
|
||||
len(omit_set),
|
||||
sorted(omit_set)[:12],
|
||||
)
|
||||
|
||||
if max_json_bytes is not None and max_json_bytes > 0 and _wire_json_size(built) > max_json_bytes:
|
||||
return _finalize(_squeeze_to_budget(built, max_json_bytes, admin=admin))
|
||||
|
||||
return _finalize(built)
|
||||
|
||||
|
||||
def _squeeze_to_budget(
|
||||
built: list[dict[str, Any]], max_json_bytes: int, *, admin: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
work = json.loads(json.dumps(built, ensure_ascii=False, default=str))
|
||||
|
||||
def sz() -> int:
|
||||
return _wire_json_size(work)
|
||||
|
||||
if sz() <= max_json_bytes:
|
||||
return work
|
||||
for i, ent in enumerate(work):
|
||||
fn = ent.get("function") if isinstance(ent.get("function"), dict) else {}
|
||||
ps = fn.get("parameters")
|
||||
if isinstance(ps, dict) and len(json.dumps(ps, ensure_ascii=False)) > 400:
|
||||
nm = str(fn.get("name") or "")
|
||||
if nm.startswith("mcp__"):
|
||||
cap = int(admin.get("minimal_desc_cap") or 80)
|
||||
work[i] = {
|
||||
"type": "function",
|
||||
"function": _tier_minimal(fn, desc_cap=max(0, cap)),
|
||||
}
|
||||
if sz() <= max_json_bytes:
|
||||
return work
|
||||
|
||||
for cap in (60, 40, 24, 12, 8, 4, 0):
|
||||
for i, ent in enumerate(work):
|
||||
fn = ent.get("function") if isinstance(ent.get("function"), dict) else {}
|
||||
nm = str(fn.get("name") or "")
|
||||
if nm.startswith("mcp__"):
|
||||
work[i] = {"type": "function", "function": _tier_minimal(fn, desc_cap=cap)}
|
||||
if sz() <= max_json_bytes:
|
||||
return work
|
||||
|
||||
idxs = [i for i, e in enumerate(work) if str((e.get("function") or {}).get("name") or "").startswith("mcp__")]
|
||||
for i in reversed(idxs):
|
||||
if sz() <= max_json_bytes:
|
||||
break
|
||||
work.pop(i)
|
||||
|
||||
if sz() > max_json_bytes:
|
||||
out = complete_openai_tools_wire_parameters(list(tools))
|
||||
if max_json_bytes is not None and int(max_json_bytes) > 0:
|
||||
from svc.llm.tool_schema import shrink_openai_tools_payload_for_api
|
||||
|
||||
work = shrink_openai_tools_payload_for_api(work, max_json_bytes=max_json_bytes)
|
||||
return work
|
||||
|
||||
|
||||
def penalty_row_status(
|
||||
*,
|
||||
wire_name: str,
|
||||
policy_level: int | None,
|
||||
penalty: dict[str, Any] | None,
|
||||
last_ts: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Human-readable row for Admin UI."""
|
||||
if policy_level is None:
|
||||
return {
|
||||
"phase": "inherit",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "未配置:走全局线侧与闲置惩罚(与显式写入 0 不同)",
|
||||
"kind": None,
|
||||
}
|
||||
if policy_level == 9999:
|
||||
return {
|
||||
"phase": "permanent_ban",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "永久不上送(需将策略改为非 9999)",
|
||||
"kind": None,
|
||||
}
|
||||
if policy_level == 0:
|
||||
return {
|
||||
"phase": "exempt",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "策略 0:不参与闲置惩罚(仍可能受线侧分层/压缩影响)",
|
||||
"kind": None,
|
||||
}
|
||||
st = penalty.get(wire_name) if isinstance(penalty, dict) else None
|
||||
if not isinstance(st, dict):
|
||||
return {"phase": "none", "omit_until": None, "unblock_hint": "-", "kind": None}
|
||||
ph = str(st.get("phase") or "")
|
||||
ou = str(st.get("omit_until") or "")
|
||||
kd = str(st.get("kind") or "")
|
||||
if ph == "active" and ou:
|
||||
dt = _parse_iso(ou)
|
||||
hint = f"惩罚中,预计解封(omit_until): {ou}"
|
||||
if dt and _utc_now() >= dt:
|
||||
hint = "惩罚窗口应已结束;下次请求会写入 done"
|
||||
return {"phase": "active", "omit_until": ou, "unblock_hint": hint, "kind": kd}
|
||||
if ph == "done":
|
||||
return {
|
||||
"phase": "done",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "本轮已服完刑;再次闲置达到阈值后会重新惩罚",
|
||||
"kind": kd,
|
||||
}
|
||||
return {"phase": ph or "unknown", "omit_until": ou or None, "unblock_hint": "-", "kind": kd or None}
|
||||
out = shrink_openai_tools_payload_for_api(out, max_json_bytes=int(max_json_bytes))
|
||||
out = complete_openai_tools_wire_parameters(out)
|
||||
return out
|
||||
|
||||
|
||||
def build_tool_wire_snapshot(store: Any, *, role: str | None = None) -> dict[str, Any]:
|
||||
"""Aggregate MCP install list + usage + policies for Admin GET."""
|
||||
from runtime.tools.mcp.registry import McpRegistry
|
||||
|
||||
admin = load_merged_admin_config(store)
|
||||
role_norm = str(role or "").strip().lower() or None
|
||||
role_mode = load_role_mode_for_role(store, role=role_norm)
|
||||
policies = load_tool_policies_dict_for_role(store, role=role_norm)
|
||||
pen = load_penalty_state_for_role(store, role=role_norm)
|
||||
usage = store.list_mcp_tool_aggregate_usage()
|
||||
servers = McpRegistry(store).list_servers(enabled_only=False)
|
||||
tools_out: list[dict[str, Any]] = []
|
||||
for s in servers:
|
||||
sid = str(s.get("server_id") or "").strip()
|
||||
if not sid:
|
||||
continue
|
||||
for t in store.list_mcp_server_tools(server_id=sid):
|
||||
short = str(t.get("tool_name") or "").strip()
|
||||
if not short:
|
||||
continue
|
||||
wire = f"mcp__{sid}__{short}"
|
||||
agg = usage.get(wire) or {}
|
||||
in_db = wire in policies
|
||||
lv = int(policies[wire]) if in_db else None
|
||||
st = penalty_row_status(
|
||||
wire_name=wire,
|
||||
policy_level=lv,
|
||||
penalty=pen,
|
||||
last_ts=str(agg.get("last_ts") or "") or None,
|
||||
)
|
||||
tools_out.append(
|
||||
{
|
||||
"server_id": sid,
|
||||
"mcp_tool_name": short,
|
||||
"wire_name": wire,
|
||||
"policy_level": lv,
|
||||
"policy_in_db": in_db,
|
||||
"count": int(agg.get("count") or 0),
|
||||
"last_ts": str(agg.get("last_ts") or ""),
|
||||
"penalty": st,
|
||||
"raw_penalty": pen.get(wire) if isinstance(pen.get(wire), dict) else None,
|
||||
}
|
||||
)
|
||||
tools_out.sort(key=lambda x: (x["server_id"], x["wire_name"]))
|
||||
"""Compat stub for removed Admin wire UI."""
|
||||
del store
|
||||
return {
|
||||
"ok": True,
|
||||
"role": role_norm or "",
|
||||
"role_mode": role_mode,
|
||||
"config": admin,
|
||||
"policies": policies,
|
||||
"penalty_state": pen,
|
||||
"tools": tools_out,
|
||||
"removed": True,
|
||||
"role": str(role or "").strip().lower(),
|
||||
"role_mode": "unrestricted",
|
||||
"config": load_merged_admin_config(None),
|
||||
"policies": {},
|
||||
"penalty_state": {},
|
||||
"tools": [],
|
||||
"message": "MCP wire suppression removed; use specialist MCP binding only.",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -784,5 +127,6 @@ __all__ = [
|
|||
"migrate_legacy_penalty_store",
|
||||
"penalty_row_status",
|
||||
"prepare_openai_tools_for_llm_api",
|
||||
"wire_graduation_effective",
|
||||
"wire_policy_enabled",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from interfaces.http.fastapi_app import create_app
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminMcpToolWireRoleModeApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_role_mode_save_and_snapshot(self) -> None:
|
||||
s = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "ops", "mode": "forbidden"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 200, s.text)
|
||||
body = s.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertEqual(body.get("role"), "ops")
|
||||
self.assertEqual(body.get("mode"), "forbidden")
|
||||
|
||||
g = self.client.get("/admin/api/mcp/tool-wire?role=ops", headers=self._h())
|
||||
self.assertEqual(g.status_code, 200, g.text)
|
||||
gb = g.json() or {}
|
||||
self.assertTrue(gb.get("ok"))
|
||||
self.assertEqual(gb.get("role"), "ops")
|
||||
self.assertEqual(gb.get("role_mode"), "forbidden")
|
||||
|
||||
def test_role_mode_rejects_unknown_role(self) -> None:
|
||||
s = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "unknown_role_x", "mode": "restricted"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 400, s.text)
|
||||
self.assertIn("invalid_role", s.text)
|
||||
|
||||
def test_policies_batch_supports_role_scope(self) -> None:
|
||||
r = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/policies/batch",
|
||||
json={"role": "ops", "level": 9999, "wire_names": ["mcp__echo__ping"]},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertEqual(body.get("role"), "ops")
|
||||
raw = str(self.store.get_setting("mcp_tool_wire_tool_policies_by_role") or "").strip() or "{}"
|
||||
obj = json.loads(raw)
|
||||
self.assertEqual(int(((obj.get("ops") or {}).get("mcp__echo__ping") or 0)), 9999)
|
||||
|
||||
def test_e2e_role_mode_then_batch_then_refresh_consistent(self) -> None:
|
||||
# 1) switch role mode for ops
|
||||
s = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "ops", "mode": "restricted"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 200, s.text)
|
||||
self.assertTrue((s.json() or {}).get("ok"))
|
||||
|
||||
# 2) apply role-scoped batch policy for ops
|
||||
b = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/policies/batch",
|
||||
json={"role": "ops", "level": 0, "wire_names": ["mcp__echo__ping"]},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(b.status_code, 200, b.text)
|
||||
bb = b.json() or {}
|
||||
self.assertTrue(bb.get("ok"))
|
||||
self.assertEqual(bb.get("role"), "ops")
|
||||
|
||||
# 3) query snapshot for ops (equivalent to refresh + reload)
|
||||
g1 = self.client.get("/admin/api/mcp/tool-wire?role=ops", headers=self._h())
|
||||
self.assertEqual(g1.status_code, 200, g1.text)
|
||||
body1 = g1.json() or {}
|
||||
self.assertTrue(body1.get("ok"))
|
||||
self.assertEqual(body1.get("role"), "ops")
|
||||
self.assertEqual(body1.get("role_mode"), "restricted")
|
||||
self.assertEqual(int((body1.get("policies") or {}).get("mcp__echo__ping") or 0), 0)
|
||||
|
||||
# 4) switch another role and ensure isolation
|
||||
s2 = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "generalist", "mode": "forbidden"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s2.status_code, 200, s2.text)
|
||||
|
||||
g2 = self.client.get("/admin/api/mcp/tool-wire?role=generalist", headers=self._h())
|
||||
self.assertEqual(g2.status_code, 200, g2.text)
|
||||
body2 = g2.json() or {}
|
||||
self.assertEqual(body2.get("role_mode"), "forbidden")
|
||||
# no accidental carry-over from ops role policies
|
||||
self.assertNotIn("mcp__echo__ping", dict(body2.get("policies") or {}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from svc.llm.tool_wire_policy import (
|
||||
SETTINGS_KEY_PENALTY_STATE,
|
||||
migrate_legacy_penalty_store,
|
||||
prepare_openai_tools_for_llm_api,
|
||||
)
|
||||
|
||||
|
||||
class FakeToolStore:
|
||||
"""Minimal store stub for ``prepare_openai_tools_for_llm_api``."""
|
||||
|
||||
def __init__(self, usage_map: dict, penalty_state: dict | None = None) -> None:
|
||||
self.usage_map = dict(usage_map)
|
||||
self._penalty_json = json.dumps(penalty_state or {})
|
||||
self.last_penalty_saved: str | None = None
|
||||
|
||||
def list_mcp_tool_aggregate_usage(self) -> dict:
|
||||
return dict(self.usage_map)
|
||||
|
||||
def get_setting(self, key: str) -> str | None:
|
||||
if key == SETTINGS_KEY_PENALTY_STATE:
|
||||
return self._penalty_json
|
||||
return None
|
||||
|
||||
def set_setting(self, key: str, val: str) -> None:
|
||||
if key == SETTINGS_KEY_PENALTY_STATE:
|
||||
self._penalty_json = val
|
||||
self.last_penalty_saved = val
|
||||
|
||||
|
||||
def _fn(name: str, *, desc: str = "hello") -> dict:
|
||||
props: dict = {"fld": {"type": "string", "description": "inner"}}
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {"type": "object", "properties": props},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _names(tools: list[dict]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for t in tools:
|
||||
fn = t.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
out.append(str(fn["name"]))
|
||||
return out
|
||||
|
||||
|
||||
def _iso_hours_ago(h: float) -> str:
|
||||
n = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
return (n - timedelta(hours=h)).isoformat()
|
||||
|
||||
|
||||
class ToolWirePolicyTests(unittest.TestCase):
|
||||
def test_builtin_tools_always_full(self) -> None:
|
||||
ts = _iso_hours_ago(1.0)
|
||||
usage = {
|
||||
"mcp__rank__1": {"count": 400, "last_ts": ts},
|
||||
"mcp__rank__2": {"count": 300, "last_ts": ts},
|
||||
"mcp__rank__3": {"count": 200, "last_ts": ts},
|
||||
"mcp__srv__x": {"count": 1, "last_ts": ts},
|
||||
}
|
||||
tools = [_fn("read_file"), _fn("mcp__rank__1"), _fn("mcp__rank__2"), _fn("mcp__rank__3"), _fn("mcp__srv__x")]
|
||||
st = FakeToolStore(usage)
|
||||
with patch("svc.llm.tool_wire_policy._utc_now", return_value=datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)):
|
||||
with patch.dict("os.environ", {"OPS_MCP_WIRE_TOP_N_FULL": "3"}, clear=False):
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
by_name = {str(t["function"]["name"]): t for t in out if t.get("type") == "function"}
|
||||
self.assertIn("inner", json.dumps(by_name["read_file"]))
|
||||
self.assertNotIn("inner", json.dumps(by_name["mcp__srv__x"]))
|
||||
|
||||
def test_graduated_minimal_below_top_n(self) -> None:
|
||||
ts = _iso_hours_ago(0.5)
|
||||
usage = {
|
||||
"mcp__p__a": {"count": 100, "last_ts": ts},
|
||||
"mcp__p__b": {"count": 90, "last_ts": ts},
|
||||
"mcp__p__c": {"count": 80, "last_ts": ts},
|
||||
"mcp__p__d": {"count": 70, "last_ts": ts},
|
||||
}
|
||||
tools = [_fn("mcp__p__a"), _fn("mcp__p__b"), _fn("mcp__p__c"), _fn("mcp__p__d")]
|
||||
st = FakeToolStore(usage)
|
||||
fixed = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("svc.llm.tool_wire_policy._utc_now", return_value=fixed):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"OPS_MCP_WIRE_TOP_N_FULL": "3",
|
||||
"OPS_MCP_WIRE_MEDIUM_RANK_START": "999",
|
||||
"OPS_MCP_WIRE_MEDIUM_RANK_END": "999",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
by_name = {str(t["function"]["name"]): t for t in out}
|
||||
self.assertIn("properties", by_name["mcp__p__a"]["function"]["parameters"])
|
||||
self.assertIn("properties", by_name["mcp__p__b"]["function"]["parameters"])
|
||||
self.assertIn("properties", by_name["mcp__p__c"]["function"]["parameters"])
|
||||
self.assertEqual(by_name["mcp__p__d"]["function"]["parameters"].get("additionalProperties"), True)
|
||||
|
||||
def test_never_logged_mcp_not_omitted(self) -> None:
|
||||
tools = [_fn("mcp__ghost__z")]
|
||||
st = FakeToolStore({})
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
self.assertEqual(_names(out), ["mcp__ghost__z"])
|
||||
|
||||
def test_stale_penalty_then_release(self) -> None:
|
||||
t0 = datetime(2026, 4, 19, 12, 0, 0, tzinfo=timezone.utc)
|
||||
stale_ts = (t0 - timedelta(hours=4)).isoformat()
|
||||
usage = {
|
||||
"mcp__st__fill1": {"count": 900, "last_ts": stale_ts},
|
||||
"mcp__st__fill2": {"count": 800, "last_ts": stale_ts},
|
||||
"mcp__st__fill3": {"count": 700, "last_ts": stale_ts},
|
||||
"mcp__st__old": {"count": 5, "last_ts": stale_ts},
|
||||
}
|
||||
tools = [_fn("mcp__st__fill1"), _fn("mcp__st__fill2"), _fn("mcp__st__fill3"), _fn("mcp__st__old")]
|
||||
st = FakeToolStore(usage)
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"OPS_MCP_WIRE_STALE_HOURS": "3", "OPS_MCP_WIRE_TOP_N_FULL": "3"},
|
||||
clear=False,
|
||||
):
|
||||
with patch("svc.llm.tool_wire_policy._utc_now", return_value=t0):
|
||||
out1 = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
self.assertEqual(_names(out1), ["mcp__st__fill1", "mcp__st__fill2", "mcp__st__fill3"])
|
||||
|
||||
pen = json.loads(st.last_penalty_saved or "{}")
|
||||
self.assertEqual(pen["mcp__st__old"]["phase"], "active")
|
||||
|
||||
t_late = t0 + timedelta(minutes=31)
|
||||
with patch("svc.llm.tool_wire_policy._utc_now", return_value=t_late):
|
||||
out2 = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
want = ["mcp__st__fill1", "mcp__st__fill2", "mcp__st__fill3", "mcp__st__old"]
|
||||
self.assertEqual(_names(out2), want)
|
||||
pen2 = json.loads(st.last_penalty_saved or "{}")
|
||||
self.assertEqual(pen2["mcp__st__old"]["phase"], "done")
|
||||
|
||||
def test_wave_ts_change_clears_penalty(self) -> None:
|
||||
t0 = datetime(2026, 4, 19, 15, 0, 0, tzinfo=timezone.utc)
|
||||
old_ts = (t0 - timedelta(hours=5)).isoformat()
|
||||
usage1 = {"mcp__w__x": {"count": 1, "last_ts": old_ts}}
|
||||
penalty1 = {"mcp__w__x": {"phase": "done", "wave_ts": old_ts}}
|
||||
tools = [_fn("mcp__w__x")]
|
||||
st = FakeToolStore(usage1, penalty1)
|
||||
|
||||
new_ts = (t0 - timedelta(minutes=30)).isoformat()
|
||||
usage2 = {"mcp__w__x": {"count": 2, "last_ts": new_ts}}
|
||||
st.usage_map = usage2
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"OPS_MCP_WIRE_STALE_HOURS": "3", "OPS_MCP_WIRE_TOP_N_FULL": "3"},
|
||||
clear=False,
|
||||
):
|
||||
with patch("svc.llm.tool_wire_policy._utc_now", return_value=t0):
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
pen = json.loads(st.last_penalty_saved or "{}")
|
||||
self.assertNotIn("mcp__w__x", pen)
|
||||
self.assertEqual(_names(out), ["mcp__w__x"])
|
||||
|
||||
def test_migrate_legacy_penalty_store(self) -> None:
|
||||
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
raw = {
|
||||
"legacy_active": {"omit_until": future, "wave_ts": "x"},
|
||||
"legacy_done": {"omit_until": past, "wave_ts": "y"},
|
||||
}
|
||||
mig = migrate_legacy_penalty_store(raw)
|
||||
self.assertEqual(mig["legacy_active"]["phase"], "active")
|
||||
self.assertEqual(mig["legacy_done"]["phase"], "done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
50
tests/test_tool_wire_policy_passthrough.py
Normal file
50
tests/test_tool_wire_policy_passthrough.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Wire suppression removed: prepare_openai_tools keeps MCP tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from svc.llm.tool_wire_policy import (
|
||||
load_role_mode_for_role,
|
||||
prepare_openai_tools_for_llm_api,
|
||||
wire_graduation_effective,
|
||||
wire_policy_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_wire_policy_disabled_by_default() -> None:
|
||||
assert wire_policy_enabled("https://api.openai.com") is False
|
||||
assert wire_graduation_effective("https://api.openai.com", {}) is False
|
||||
assert load_role_mode_for_role(None, role="ops") == "unrestricted"
|
||||
|
||||
|
||||
def test_prepare_openai_tools_keeps_mcp_tools() -> None:
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__demo__ping",
|
||||
"description": "ping",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "read",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://example",
|
||||
max_json_bytes=None,
|
||||
store=None,
|
||||
role="ops",
|
||||
)
|
||||
names = [
|
||||
str(((e.get("function") or {}).get("name") if isinstance(e, dict) else "") or "")
|
||||
for e in out
|
||||
]
|
||||
assert "mcp__demo__ping" in names
|
||||
assert "read_file" in names
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
from svc.llm.tool_wire_policy import prepare_openai_tools_for_llm_api
|
||||
|
||||
|
||||
def test_manager_role_respects_permanent_ban_by_default(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}')
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}},
|
||||
{"type": "function", "function": {"name": "system_time", "description": "t", "parameters": {"type": "object"}}},
|
||||
]
|
||||
out_mgr = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="manager")
|
||||
out_gen = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="generalist")
|
||||
mgr_names = {t["function"]["name"] for t in out_mgr if isinstance(t, dict) and t.get("type") == "function"}
|
||||
gen_names = {t["function"]["name"] for t in out_gen if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" not in mgr_names
|
||||
assert "mcp__echo__ping" not in gen_names
|
||||
|
||||
|
||||
def test_role_scoped_policies_override_global(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
# Global ban
|
||||
store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}')
|
||||
# Role override allows it (policy 0)
|
||||
store.set_setting("mcp_tool_wire_tool_policies_by_role", '{"ops":{"mcp__echo__ping":0}}')
|
||||
tools = [{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}]
|
||||
out_ops = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="ops")
|
||||
out_gen = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="generalist")
|
||||
ops_names = {t["function"]["name"] for t in out_ops if isinstance(t, dict) and t.get("type") == "function"}
|
||||
gen_names = {t["function"]["name"] for t in out_gen if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" in ops_names
|
||||
assert "mcp__echo__ping" not in gen_names
|
||||
|
||||
|
||||
def test_role_mode_unrestricted_disables_penalty_but_keeps_ban(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}')
|
||||
store.set_setting("mcp_tool_wire_role_mode_by_role", '{"manager":"unrestricted"}')
|
||||
tools = [{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}]
|
||||
out_mgr = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="manager")
|
||||
names = {t["function"]["name"] for t in out_mgr if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" not in names
|
||||
|
||||
|
||||
def test_role_mode_forbidden_disables_all_mcp(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("mcp_tool_wire_role_mode_by_role", '{"ops":"forbidden"}')
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}},
|
||||
{"type": "function", "function": {"name": "system_time", "description": "t", "parameters": {"type": "object"}}},
|
||||
]
|
||||
out_ops = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="ops")
|
||||
names = {t["function"]["name"] for t in out_ops if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" not in names
|
||||
assert "system_time" in names
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue