feat(agent): 五级作用域工具解析+patch_file+原生视觉上传构造

1. tool_sources.py(新): global/org/pipeline/role/project 五级工具作用域解析
   - 策略表 pipeline_tool_policies allow/deny(配套 models+CRUD json)
   - capability 两层语义: 映射手册(all/概念技能,含global层) + 声明注入
     (仅org/pipeline/role/project/user层)——防 generic 会话经 global 概念
     技能拿到~70个产线工具,击穿七层隔离(单测 29/29 含此回归)
   - role 白名单是过滤器非添加器,且作用于 base+capability 全集
2. agent_config: GENERAL_TOOLS 加 patch_file(定点替换,唯一性校验);
   write_file 描述引导改文件优先用 patch_file
3. upload_tools: is_image + build_image_parts(OpenAI多模态data URL,
   8MB/张+4张/条上限,超限显式告知不静默丢)
4. agent_chat/agent_chat_generic: 图片分流不进文本上下文,
   image_paths 传 gateway
5. load_path: 策略管理页注册
This commit is contained in:
yumoqing 2026-09-10 18:36:06 +08:00
parent 43f3ad48e5
commit b399663863
8 changed files with 456 additions and 5 deletions

View File

@ -0,0 +1,47 @@
{
"tblname": "pipeline_tool_policies",
"title": "工具作用域策略",
"params": {
"sortby": [
"scope asc",
"scope_id asc",
"created_at desc"
],
"confidential_fields": [],
"browserfields": {
"exclouded": [],
"alters": {
"scope": {
"uitype": "code",
"data": [
{"value": "org", "text": "机构"},
{"value": "pipeline", "text": "产线"},
{"value": "role", "text": "角色"},
{"value": "project", "text": "项目"}
]
},
"effect": {
"uitype": "code",
"data": [
{"value": "allow", "text": "允许"},
{"value": "deny", "text": "禁用"}
]
},
"status": {
"uitype": "code",
"data": [
{"value": "active", "text": "生效"},
{"value": "invalid", "text": "停用"}
]
}
}
},
"editexclouded": [
"id",
"created_at",
"updated_at",
"created_by"
],
"editable": {}
}
}

View File

@ -0,0 +1,106 @@
{
"summary": [
{
"name": "pipeline_tool_policies",
"title": "工具作用域策略",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "scope",
"title": "作用域(org/pipeline/role/project)",
"type": "str",
"length": 32,
"nullable": "no",
"default": "org"
},
{
"name": "scope_id",
"title": "作用域ID(机构ID/产线ID/产线:角色/项目ID)",
"type": "str",
"length": 128,
"nullable": "no",
"default": ""
},
{
"name": "tool_name",
"title": "工具名",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "effect",
"title": "效果(allow允许/deny禁用)",
"type": "str",
"length": 16,
"nullable": "no",
"default": "allow"
},
{
"name": "reason",
"title": "配置原因(审计用)",
"type": "str",
"length": 500,
"nullable": "yes"
},
{
"name": "status",
"title": "状态(active/invalid)",
"type": "str",
"length": 16,
"nullable": "no",
"default": "active"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32,
"nullable": "yes"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": "no"
}
],
"indexes": [
{
"name": "idx_scope",
"idxtype": "index",
"idxfields": [
"scope",
"scope_id",
"status"
]
},
{
"name": "uk_scope_tool",
"idxtype": "unique",
"idxfields": [
"scope",
"scope_id",
"tool_name"
]
}
]
}

View File

@ -310,10 +310,17 @@ GENERAL_TOOLS = [
),
ToolDefinition(
name="write_file",
description="写入文件到工作空间(自动创建父目录)",
description="写入文件到工作空间(自动创建父目录)。整文件覆盖——修改已有文件优先用 patch_file定点替换只有新建或全文重写才用本工具",
parameters={"path": "相对路径", "content": "文件内容"},
category="file",
),
ToolDefinition(
name="patch_file",
description="定点修改工作空间中的文本文件找原文片段→替换新文本。old_string 必须在文件中唯一出现出现0次或多次都拒绝多次时请带更多上下文old_string 须与文件内容完全一致(含空白/缩进,先用 read_file 核对)。新建文件用 write_file",
parameters={"path": "相对路径", "old_string": "要替换的原文片段(须唯一)", "new_string": "替换后的新文本(空字符串=删除该片段)", "replace_all": "可选true=替换全部出现默认false须唯一"},
required=["path", "old_string", "new_string"],
category="file",
),
ToolDefinition(
name="list_files",
description="列出工作空间目录内容",

View File

@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""tool_sources.py — 五级作用域工具解析2026-09-10
用户需求全局/机构/产线/角色/项目 五个维度各自注册不同工具产线特有工具
按产线区分此前只有两层GENERAL_TOOLS 全局 + PipelineAbility 按产线
机构/角色/项目维度缺失 v2 会话根本没用 capability frontmatter 驱动的工具
resolve_capability_tools 只有 v1 agent_loop 在用
五级作用域优先级低同技能 SCOPE_PRIORITY 语义
| | 工具来源 | 隔离机制 |
|----|---------|---------|
| 1 global | GENERAL_TOOLScore 代码 | generic 会话剔除 project/data |
| 2 org | 机构装的技能包 capability 工具 + 策略表 allow/deny | 技能 get_merged org 隔离orgs/{org}/ 物理目录 |
| 3 pipeline | PipelineAbility.tools + 产线技能 capability 工具 | 注册表按 pipeline_idgeneric 不挂 |
| 4 role | RoleSpec.tools角色声明的工具名+ 角色技能 capability 工具 | get_role_spec(pipeline_id, role) 按产线隔离 |
| 5 project | 策略表 allow/deny项目级微调 | 策略行 scope_id=project_id |
**关键设计技能作用域即工具作用域** 技能包里 frontmatter 声明
`capability: xxx_capability` + `tools: [a, b]``get_merged` 已按六级可见性
过滤globalorgpipelineroleprojectuser所以机构 A 装的包只在 A
merged 里出现 capability 工具只对 A 可见复用已验证的技能隔离不另造一套
**执行层强制门禁**resolve 返回 allowed 集合AgentExecutor._execute_tool
先查集合再分发LLM 幻觉调用不属于本会话作用域的工具名 generic 会话喊
create_taskA 产线喊 B 产线工具时直接拒绝并给出可行动提示而不是只有
schema 里不给看schema 防线挡不住幻觉历史踩过别名绕过能力包同名工具
绕过通用层安全门
"""
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
logger = logging.getLogger("pipeline.tool_sources")
# 策略表 effect 取值
EFFECT_ALLOW = "allow"
EFFECT_DENY = "deny"
@dataclass
class ToolScopeResult:
"""五级解析结果。"""
tools: List = field(default_factory=list) # [ToolDefinition] 最终 schema 来源
allowed: Set[str] = field(default_factory=set) # 执行层门禁允许集(工具名)
capability_tools: List[str] = field(default_factory=list) # frontmatter 驱动的工具名
trace: List[str] = field(default_factory=list) # 诊断:每级命中/剔除记录
async def load_policies(sor, scope: str, scope_id: str) -> List[Tuple[str, str]]:
"""读策略表某作用域的 (tool_name, effect) 列表。表缺失/查询失败 → 空(不阻断会话)。"""
if not scope_id:
return []
try:
recs = await sor.sqlExe(
"SELECT tool_name, effect FROM pipeline_tool_policies "
"WHERE scope=${s}$ AND scope_id=${sid}$ AND status='active' "
"ORDER BY created_at ASC",
{"s": scope, "sid": scope_id})
return [(getattr(r, "tool_name", "") or "", getattr(r, "effect", "") or "")
for r in (recs or []) if getattr(r, "tool_name", "")]
except Exception as e:
# 表不存在(未跑迁移)或查询失败:记 WARN 并按「无策略」继续,
# 绝不因策略表问题让整个会话起不来(静默吞错的反面:有日志、有降级语义)
logger.warning(f"tool policy load failed scope={scope} id={scope_id}: {e}")
return []
def _apply_policies(names: Set[str], policies: List[Tuple[str, str]],
trace: List[str], level: str) -> Set[str]:
"""按序应用 allow/deny。deny 优先于后续 allow同一级内后写的 deny 生效)。"""
out = set(names)
for tool_name, effect in policies:
if effect == EFFECT_DENY:
if tool_name in out:
out.discard(tool_name)
trace.append(f"{level}: deny {tool_name}")
elif effect == EFFECT_ALLOW:
if tool_name not in out:
out.add(tool_name)
trace.append(f"{level}: allow {tool_name}")
return out
# capability 声明只在「会话上下文层」生效的技能 scopeglobal 层排除,见下)
_CAPABILITY_DECLARING_SCOPES = ("org", "pipeline", "role", "project", "user")
def _collect_capability_tools(merged_skills) -> Tuple[List[str], Dict[str, str]]:
"""从可见技能 frontmatter 收集 capability 工具(两层语义,与 v1 一致)。
merged_skills: SkillLoader.get_merged() 结果已按六级可见性过滤
纯读 Skill.capability / Skill.toolscore 自有数据结构不依赖 service
避免 coreservice 分层倒置执行exec_capability_tool仍在 service
两层语义2026-09-10 隔离走查修复别简化成一层
1. capability 含哪些工具映射从全部可见技能收集global 层的
all/featureall/bug 等概念规范技能 = 映射手册
2. 本会话需要哪些 capability只认 org/pipeline/role/project/user
技能的声明**global 层声明不作为注入依据**
为什么all/ 10 个概念技能声明了 ~70 个产线工具propose_feature/
claim_task/create_projectgeneric 会话的 get_merged 只加载 global 技能
若一层直取通用助手会拿到全部产线概念工具击穿 2026-09-05 七层隔离
v1 同款设计角色 frontmatter 声明我需要 capability+ 规范技能声明
capability 含哪些工具
返回 (工具名列表, 工具名capability 映射)
"""
cap_map: Dict[str, List[str]] = {} # capability → 工具清单(全部可见技能)
declared: Set[str] = set() # 本会话声明需要的 capability非 global 层)
for skill in (merged_skills or {}).values():
cap = getattr(skill, "capability", "") or ""
if not cap:
continue
tools = getattr(skill, "tools", []) or []
bucket = cap_map.setdefault(cap, [])
for t in tools:
if t not in bucket:
bucket.append(t)
scope = getattr(skill, "scope", "global") or "global"
if scope in _CAPABILITY_DECLARING_SCOPES:
declared.add(cap)
names, owner = [], {}
for cap in sorted(declared): # 只展开被声明的 capability
for t in cap_map.get(cap, []):
if t not in owner:
names.append(t)
owner[t] = cap
return names, owner
async def resolve_scoped_tools(
sor,
base_tools: List,
merged_skills=None,
generic: bool = False,
org_id: str = "",
user_id: str = "",
pipeline_id: str = "",
role: str = "",
project_id: str = "",
) -> ToolScopeResult:
"""五级作用域解析主入口。
base_tools: 已按 generic 裁剪过的 GENERAL_TOOLSload_agent_config 产物
即第 1 global 的结果
merged_skills: SkillLoader.get_merged(...) 结果None 时跳过 capability
返回 ToolScopeResulttools schema 定义allowed 是执行门禁集合
"""
res = ToolScopeResult()
tools = list(base_tools or [])
by_name = {t.name: t for t in tools}
allowed = set(by_name.keys())
trace = res.trace
trace.append(f"global: {len(allowed)} 工具generic={generic}")
# ── 2. org策略表 allow/denycapability 工具在第 6 步统一收集)──
org_policies = await load_policies(sor, "org", org_id)
if org_policies:
allowed = _apply_policies(allowed, org_policies, trace, f"org({org_id})")
# ── 3. pipeline能力包工具generic 不挂;已由 load_agent_config 合并进 base_tools
# 这里只补策略表级的产线 deny/allow──
pl_policies = await load_policies(sor, "pipeline", pipeline_id) if not generic else []
if pl_policies:
allowed = _apply_policies(allowed, pl_policies, trace, f"pipeline({pipeline_id})")
if not generic and pipeline_id:
try:
from .ability import get_ability
ab = get_ability(pipeline_id)
if ab:
trace.append(f"pipeline({pipeline_id}): 能力包 {len(ab.tools)} 工具")
except Exception as e:
logger.warning(f"ability lookup failed: {e}")
# ── 4. capability 工具(技能 frontmatter 驱动,作用域=技能可见性)。
# 必须在 role 收窄**之前**并入——角色白名单要作用于全集(base+capability)
# 否则角色声明之外的技能工具会绕过白名单2026-09-10 单测实抓)。──
cap_names: List[str] = []
if merged_skills:
cap_names, cap_owner = _collect_capability_tools(merged_skills)
if cap_names:
res.capability_tools = cap_names
allowed |= set(cap_names)
trace.append(f"capability(技能声明): {len(cap_names)} 工具 "
f"[{', '.join(cap_names[:8])}{'...' if len(cap_names) > 8 else ''}]")
# ── 5. roleRoleSpec.tools 是角色声明的工具名白名单(非空则收窄)──
if role and pipeline_id and not generic:
try:
from .ability import get_role_spec
spec = get_role_spec(pipeline_id, role)
if spec and getattr(spec, "tools", None):
declared = set(spec.tools)
before = len(allowed)
# 角色白名单只收窄「能力包/通用」里角色未声明的部分;
# ask_user/todo/memory 等交互基础工具始终保留(否则角色 agent 无法提问)
keep_always = {"ask_user", "todo", "memory", "load_skill",
"read_file", "write_file", "list_files"}
allowed = {n for n in allowed if n in declared or n in keep_always}
trace.append(f"role({role}): 声明 {len(declared)} 工具,"
f"{before}{len(allowed)}")
except Exception as e:
logger.warning(f"role spec lookup failed: {e}")
role_policies = await load_policies(sor, "role", f"{pipeline_id}:{role}") if role else []
if role_policies:
allowed = _apply_policies(allowed, role_policies, trace, f"role({role})")
# ── 6. project项目级微调 ──
pj_policies = await load_policies(sor, "project", project_id)
if pj_policies:
allowed = _apply_policies(allowed, pj_policies, trace, f"project({project_id})")
# ── 最终 schemabase_tools 里被 deny 的剔除capability 工具无 ToolDefinition
# schema 由 capability_tools.TOOL_SCHEMAS 提供,执行走 exec_capability_tool
res.tools = [t for t in tools if t.name in allowed]
res.allowed = allowed
return res

View File

@ -94,6 +94,63 @@ def save_uploads(target_dir, uploads):
return saved
IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')
IMAGE_MIME = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp'}
IMAGE_MAX_BYTES = 8 * 1024 * 1024 # 单图上限 8MBbase64 后约 10.7MB
IMAGE_MAX_COUNT = 4 # 单条消息最多 4 张
def is_image(name: str) -> bool:
"""按扩展名判断是否图片(原生视觉链路用)。"""
return os.path.splitext((name or '').lower())[1] in IMAGE_EXTS
def build_image_parts(paths):
"""把上传图片构造为 OpenAI 多模态 content partsdata URL
paths: [(abs_path, filename)]
返回 (parts, notes)
parts = [{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}]
notes = 注入 prompt 的文本说明图片清单/超限拒绝绝不静默丢弃
沿用 extract_text显式截断告知的同一原则
上限策略单图 >8MB 或超过 4 超限的图不进 partsnotes 显式告知
文件名+原因+替代手段invoke_model i2t 或压缩后重传agent 可转达用户
"""
import base64
parts, notes, used = [], [], 0
for src, name in (paths or []):
ext = os.path.splitext((name or '').lower())[1]
mime = IMAGE_MIME.get(ext)
if not mime:
notes.append(f"图片 {name}:不支持的格式(支持 {'/'.join(IMAGE_EXTS)}),未注入。")
continue
try:
size = os.path.getsize(src)
except OSError:
notes.append(f"图片 {name}:文件不可读,未注入。")
continue
if size > IMAGE_MAX_BYTES:
notes.append(f"图片 {name}:超过 {IMAGE_MAX_BYTES // 1024 // 1024}MB 上限"
f"(实际 {size // 1024 // 1024}MB未注入。请压缩后重传。")
continue
if used >= IMAGE_MAX_COUNT:
notes.append(f"图片 {name}:超过单条消息 {IMAGE_MAX_COUNT} 张上限,未注入。")
continue
try:
with open(src, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('ascii')
except Exception:
notes.append(f"图片 {name}:读取失败,未注入。")
continue
parts.append({"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"}})
notes.append(f"图片 {name}(已注入,你可以直接看到内容)")
used += 1
return parts, notes
def build_file_context(items):
"""items: [(filename, relpath, preview, total, truncated, is_binary)]

View File

@ -11,6 +11,8 @@ MOD = "pipeline_core"
PATHS_LOGINED = [
f"/{MOD}",
# 工具作用域策略管理页2026-09-10五级作用域工具解析的 allow/deny 配置)
f"/{MOD}/pipeline_tool_policies/*",
]

View File

@ -37,9 +37,10 @@ if action == 'send_message':
# 处理用户上传的文件multipart file 字段 → web_path
# 统一走 pipeline_core.upload_tools——截断显式告知、落盘位置=agent 可读位置。
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context, is_image
_items = [] # build_file_context 入参
_uploads = [] # [(src_abs, filename)]
_img_uploads = [] # 图片上传原生视觉2026-09-10不进文本上下文走多模态注入
_fval = (params_kw or {}).get('file')
_fpaths = _fval if isinstance(_fval, list) else ([_fval] if _fval else [])
for _fp in _fpaths:
@ -115,6 +116,12 @@ if action == 'send_message':
_saved = save_uploads(_udir, _uploads)
_byname = {os.path.basename(src): n for (src, _n), (n, _p) in zip(_uploads, _saved)}
for _src, _name in _uploads:
if is_image(_name):
# 图片走原生视觉gateway.build_image_parts不进文本上下文——
# 避免注入「二进制无法读取」与「图片已注入」矛盾提示。
# 仍随 save_uploads 落盘agent 可再用 invoke_model i2t 处理)
_img_uploads.append((_src, _name))
continue
_preview, _total, _trunc = extract_text(_src, _name)
_isbin = (not _preview and _total == 0)
_relp = (_rel + _byname.get(_name, _name)) if _name in _byname else ''
@ -134,7 +141,7 @@ if action == 'send_message':
async def agent_stream():
_base = entire_url("/")
async for chunk in gateway.run_message("web", uid, prompt, base_url=_base, session_id=session_id, pipeline_id=pipeline_id, model_id=model_id):
async for chunk in gateway.run_message("web", uid, prompt, base_url=_base, session_id=session_id, pipeline_id=pipeline_id, model_id=model_id, image_paths=_img_uploads or None):
data = json.loads(chunk)
t = data.get('type', '')

View File

@ -36,8 +36,9 @@ if action == 'send_message':
import os
from ahserver.filestorage import FileStorage
from sqlor.dbpools import DBPools
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context
from pipeline_core.upload_tools import extract_text, resolve_upload_dir, save_uploads, build_file_context, is_image
_uploads = []
_img_uploads = [] # 图片上传原生视觉2026-09-10不进文本上下文走多模态注入
_fval = (params_kw or {}).get('file')
_fpaths = _fval if isinstance(_fval, list) else ([_fval] if _fval else [])
for _fp in _fpaths:
@ -55,6 +56,12 @@ if action == 'send_message':
_byname = {os.path.basename(src): n for (src, _n), (n, _p) in zip(_uploads, _saved)}
_items = []
for _src, _name in _uploads:
if is_image(_name):
# 图片走原生视觉gateway.build_image_parts不进文本上下文——
# 否则会注入「二进制文件无法读取文本」与「图片已注入」矛盾提示。
# 仍随 save_uploads 落盘工作空间agent 可再用 invoke_model i2t 处理)
_img_uploads.append((_src, _name))
continue
_preview, _total, _trunc = extract_text(_src, _name)
_isbin = (not _preview and _total == 0)
_relp = (_rel + _byname.get(_name, _name)) if _name in _byname else ''
@ -66,7 +73,7 @@ if action == 'send_message':
pass
async def agent_stream():
async for chunk in gateway.run_message("web", uid, prompt, generic=True, model_id=model_id):
async for chunk in gateway.run_message("web", uid, prompt, generic=True, model_id=model_id, image_paths=_img_uploads or None):
data = json.loads(chunk)
t = data.get('type', '')
if t == 'tool_call':