feat: 工具系统迁移 + 重型插件骨架 + 前端交互增强
- 工具系统从 kilostar/plugin/tool_plugin/ 迁移到 data/toolset/(manifest.json 声明式) - 新增 plugin_runtime 模块:BaseOrganization / GlobalPluginManager / loader / tool_bridge - 新增 org_task + org_task_event 表及 DAO(alembic 0009) - 新增 /api/v1/plugin 路由(submit/status/stream/install/reload) - 新增 data/plugin/example_dept 示例重型插件 - regulatory_node 支持聊天历史上下文注入 - send_file 改为 artifact 存盘 + SSE 推送下载链接 - 前端 WorkflowFileCard 组件 + ToolSettings README 渲染 - utils 整理:合并 access/role_check、standalone_proxy→ray_compat、删除废弃模块 - 项目结构文档移至 docs/STRUCTURE.md 并详细展开 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,8 +28,8 @@ def _tpl(owner: str = "alice", is_builtin: bool = False):
|
||||
|
||||
@pytest.fixture
|
||||
def app(monkeypatch):
|
||||
import kilostar.utils.check_user.role_check as rc
|
||||
monkeypatch.setattr(rc, "get_authority", AsyncMock(return_value=UserAuthority.USER))
|
||||
import kilostar.utils.access as access_mod
|
||||
monkeypatch.setattr(access_mod, "get_authority", AsyncMock(return_value=UserAuthority.USER))
|
||||
_app = FastAPI()
|
||||
_app.include_router(agent_router)
|
||||
_app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user()
|
||||
|
||||
@@ -21,17 +21,16 @@ def _fake_user(user_id: str = "alice"):
|
||||
@pytest.fixture
|
||||
def app_with_user(monkeypatch):
|
||||
"""挂上 resource_router;用 dependency_overrides 跳过 JWT,并把 get_authority 默认放成 USER。"""
|
||||
import kilostar.utils.access as access_mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(resource_router)
|
||||
app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice")
|
||||
|
||||
# 默认把权限置为 USER;具体 case 内部可再 monkeypatch 覆盖
|
||||
async def _default_authority(uid):
|
||||
return UserAuthority.USER
|
||||
|
||||
monkeypatch.setattr(
|
||||
"kilostar.utils.check_user.role_check.get_authority", _default_authority
|
||||
)
|
||||
monkeypatch.setattr(access_mod, "get_authority", _default_authority)
|
||||
return app
|
||||
|
||||
|
||||
@@ -87,9 +86,8 @@ async def test_get_custom_toolset_allowed_for_admin(
|
||||
async def _admin(uid):
|
||||
return UserAuthority.SUPER_ADMINISTRATOR
|
||||
|
||||
monkeypatch.setattr(
|
||||
"kilostar.utils.check_user.role_check.get_authority", _admin
|
||||
)
|
||||
import kilostar.utils.access as access_mod
|
||||
monkeypatch.setattr(access_mod, "get_authority", _admin)
|
||||
|
||||
transport = ASGITransport(app=app_with_user)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@@ -1,41 +1,89 @@
|
||||
"""``plugin/tool_plugin`` 下各工具的元数据类正确性。
|
||||
"""``data/toolset/`` manifest.json 加载正确性测试。
|
||||
|
||||
``BaseToolData`` 本身不带 ``name`` 字段;工具名以目录名为准(由 ``GlobalToolManager``
|
||||
扫描时注入到 ``tool_metadata`` 中)。这里只验证子类对 BaseToolData 字段的覆写。
|
||||
验证 GlobalToolManager 从 manifest.json 正确读取工具元数据。
|
||||
"""
|
||||
|
||||
from kilostar.plugin.tool_plugin.approval.approval import ApprovalToolData
|
||||
from kilostar.plugin.tool_plugin.file_reader import FileReaderToolData
|
||||
from kilostar.plugin.tool_plugin.tavily_search import TavilySearchToolData
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_toolset_dir = Path(__file__).parent.parent.parent / "data" / "toolset"
|
||||
_base_toolset_dir = _toolset_dir / "base_toolset"
|
||||
_interactive_toolset_dir = _toolset_dir / "interactive_toolset"
|
||||
|
||||
|
||||
def _read_manifest(toolset_dir=_base_toolset_dir):
|
||||
with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _get_tool_def(manifest, name):
|
||||
for tool in manifest["tools"]:
|
||||
if tool["name"] == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def test_manifest_json_exists():
|
||||
assert (_base_toolset_dir / "manifest.json").exists()
|
||||
assert (_interactive_toolset_dir / "manifest.json").exists()
|
||||
|
||||
|
||||
def test_manifest_has_all_tools():
|
||||
base_names = {t["name"] for t in _read_manifest(_base_toolset_dir)["tools"]}
|
||||
interactive_names = {
|
||||
t["name"] for t in _read_manifest(_interactive_toolset_dir)["tools"]
|
||||
}
|
||||
assert base_names == {
|
||||
"shell_executor", "file_reader", "edit_file", "write_file",
|
||||
"search_file", "python_executor", "tavily_search",
|
||||
}
|
||||
assert interactive_names == {"approval", "send_file"}
|
||||
|
||||
|
||||
def test_approval_metadata():
|
||||
data = ApprovalToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
# action_scope 为空表示分配给 default 组(所有节点可用)
|
||||
assert data.action_scope == []
|
||||
|
||||
|
||||
def test_file_reader_metadata():
|
||||
data = FileReaderToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
assert data.action_scope == []
|
||||
manifest = _read_manifest(_interactive_toolset_dir)
|
||||
tool = _get_tool_def(manifest, "approval")
|
||||
assert tool["is_system"] is True
|
||||
assert tool["category"] == "system"
|
||||
assert tool["action_scope"] == []
|
||||
|
||||
|
||||
def test_tavily_search_metadata():
|
||||
data = TavilySearchToolData()
|
||||
assert data.is_system is False
|
||||
assert data.category == "search"
|
||||
assert "control_node" in data.action_scope
|
||||
assert "consciousness_node" in data.action_scope
|
||||
# 默认配置 schema 含 api_key 字段(用于 GSM 配置面板)
|
||||
assert "api_key" in data.config_args
|
||||
manifest = _read_manifest()
|
||||
tool = _get_tool_def(manifest, "tavily_search")
|
||||
assert tool["is_system"] is False
|
||||
assert tool["category"] == "search"
|
||||
assert "control_node" in tool["action_scope"]
|
||||
assert "consciousness_node" in tool["action_scope"]
|
||||
assert "api_key" in tool["config_args"]
|
||||
|
||||
|
||||
def test_base_tool_extra_allowed():
|
||||
"""``ConfigDict(extra="allow")`` 允许子类外的 KV 也能装进来。"""
|
||||
data = ApprovalToolData(some_extension="ok") # type: ignore[call-arg]
|
||||
assert data.model_extra is not None
|
||||
assert data.model_extra.get("some_extension") == "ok"
|
||||
def test_all_tool_files_exist():
|
||||
for toolset_dir in (_base_toolset_dir, _interactive_toolset_dir):
|
||||
manifest = _read_manifest(toolset_dir)
|
||||
for tool in manifest["tools"]:
|
||||
file_path = toolset_dir / tool["file"]
|
||||
assert file_path.exists(), f"Missing tool file: {tool['file']}"
|
||||
|
||||
|
||||
def test_tool_manager_loads_all_tools():
|
||||
from kilostar.core.global_state_machine.tool_manager import GlobalToolManager
|
||||
|
||||
tm = GlobalToolManager()
|
||||
assert len(tm.tool_metadata) == 9
|
||||
assert "shell_executor" in tm.tool_metadata
|
||||
assert "tavily_search" in tm.tool_metadata
|
||||
assert "approval" in tm.tool_metadata
|
||||
|
||||
|
||||
def test_tool_manager_system_vs_third_party():
|
||||
from kilostar.core.global_state_machine.tool_manager import GlobalToolManager
|
||||
|
||||
tm = GlobalToolManager()
|
||||
system_tools = tm.get_system_tools()
|
||||
third_party = tm.get_third_party_tools()
|
||||
system_names = {t["name"] for t in system_tools}
|
||||
tp_names = {t["name"] for t in third_party}
|
||||
assert "shell_executor" in system_names
|
||||
assert "tavily_search" in tp_names
|
||||
assert "tavily_search" not in system_names
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for the heavy plugin (Organization) runtime."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.agents_config import AgentsConfig
|
||||
from kilostar.plugin_runtime.loader import discover_plugins, load_plugin
|
||||
from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
|
||||
|
||||
_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "data" / "plugin"
|
||||
_EXAMPLE_DEPT = _PLUGIN_ROOT / "example_dept"
|
||||
|
||||
|
||||
def test_example_dept_structure():
|
||||
"""example_dept stub has the required files."""
|
||||
assert (_EXAMPLE_DEPT / "manifest.json").exists()
|
||||
assert (_EXAMPLE_DEPT / "agents.json").exists()
|
||||
assert (_EXAMPLE_DEPT / "README.md").exists()
|
||||
assert (_EXAMPLE_DEPT / "core" / "organization.py").exists()
|
||||
|
||||
|
||||
def test_example_dept_manifest_valid():
|
||||
with open(_EXAMPLE_DEPT / "manifest.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
manifest = OrgManifest.model_validate(data)
|
||||
assert manifest.name == "example_dept"
|
||||
assert manifest.actor_name == "org_example_dept"
|
||||
assert manifest.entry == "core.organization:ExampleOrganization"
|
||||
|
||||
|
||||
def test_example_dept_agents_valid():
|
||||
with open(_EXAMPLE_DEPT / "agents.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
config = AgentsConfig.model_validate(data)
|
||||
assert len(config.agents) == 2
|
||||
names = {a.name for a in config.agents}
|
||||
assert names == {"analyst", "executor"}
|
||||
assert config.orchestration.entry == "analyst"
|
||||
analyst = config.get("analyst")
|
||||
assert analyst is not None
|
||||
assert "executor" in analyst.peers
|
||||
|
||||
|
||||
def test_discover_plugins_finds_example_dept():
|
||||
plugins = discover_plugins(_PLUGIN_ROOT)
|
||||
names = {p.name for p in plugins}
|
||||
assert "example_dept" in names
|
||||
|
||||
|
||||
def test_load_plugin_returns_class():
|
||||
cls, manifest_dict, agents_dict, dir_str = load_plugin(_EXAMPLE_DEPT)
|
||||
assert cls.__name__ == "ExampleOrganization"
|
||||
assert manifest_dict["name"] == "example_dept"
|
||||
assert "agents" in agents_dict
|
||||
assert dir_str == str(_EXAMPLE_DEPT)
|
||||
|
||||
|
||||
def test_make_dispatch_tool_signature():
|
||||
tool = make_dispatch_tool("example_dept", "示例部门", "演示用")
|
||||
assert tool.__name__ == "dispatch_to_example_dept"
|
||||
assert callable(tool)
|
||||
assert "演示用" in tool.__doc__
|
||||
@@ -1,4 +1,4 @@
|
||||
"""standalone_proxy 适配层单元测试。
|
||||
"""ray_compat 适配层单元测试。
|
||||
|
||||
验证 StandaloneProxy / _MethodProxy / actor_class / remote_task
|
||||
在单机模式下的行为是否正确模拟了 Ray Actor Handle 的 .remote() 接口。
|
||||
@@ -7,8 +7,8 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from kilostar.utils import standalone_proxy
|
||||
from kilostar.utils.standalone_proxy import StandaloneProxy, _MethodProxy
|
||||
from kilostar.utils import ray_compat
|
||||
from kilostar.utils.ray_compat import StandaloneProxy, _MethodProxy
|
||||
|
||||
|
||||
class TestMethodProxy:
|
||||
@@ -61,9 +61,9 @@ class TestStandaloneProxy:
|
||||
|
||||
class TestActorClass:
|
||||
def test_standalone_returns_class_unchanged(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.actor_class
|
||||
@ray_compat.actor_class
|
||||
class MyActor:
|
||||
def do_work(self):
|
||||
return 42
|
||||
@@ -72,9 +72,9 @@ class TestActorClass:
|
||||
assert instance.do_work() == 42
|
||||
|
||||
def test_standalone_class_is_plain_python(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.actor_class
|
||||
@ray_compat.actor_class
|
||||
class MyActor:
|
||||
pass
|
||||
|
||||
@@ -84,9 +84,9 @@ class TestActorClass:
|
||||
|
||||
class TestRemoteTask:
|
||||
def test_sync_task(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.remote_task
|
||||
@ray_compat.remote_task
|
||||
def multiply(a, b):
|
||||
return a * b
|
||||
|
||||
@@ -95,9 +95,9 @@ class TestRemoteTask:
|
||||
assert result == 12
|
||||
|
||||
def test_async_task(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.remote_task
|
||||
@ray_compat.remote_task
|
||||
async def async_multiply(a, b):
|
||||
return a * b
|
||||
|
||||
Reference in New Issue
Block a user