This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
+5 -1
View File
@@ -21,12 +21,16 @@ class AgentDef(BaseModel):
``tools`` / ``skills`` 名字按下面顺序解析:
1. 本组织 toolset/ 里声明的工具
2. cabinet 全局工具白名单(python_executor 等基础工具)
``model`` 留空表示这是一个 **slot**:插件不指定 provider/model,由用户在前端
Agent 设置页装配。组织实际构建 agent 时从 DB 中按 ``(plugin, slot)`` 查询用户
配置;查不到则跳过该 slot 并日志告警。
"""
name: str
role: str = ""
system_prompt: str = ""
model: AgentModelRef
model: Optional[AgentModelRef] = None
tools: List[str] = Field(default_factory=list)
skills: List[str] = Field(default_factory=list)
peers: List[str] = Field(default_factory=list)
+125 -15
View File
@@ -12,14 +12,14 @@ from __future__ import annotations
import asyncio
import json
import time
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Type
from ulid import ULID
from kilostar.plugin_runtime.event import OrgEvent, TaskState
from kilostar.plugin_runtime.manifest import OrgManifest
from kilostar.plugin_runtime.agents_config import AgentsConfig, AgentDef
from kilostar.utils.logger import get_logger
from kilostar.utils.settings import get_artifact_dir
from kilostar.utils.settings import get_artifact_dir, get_plugin_data_dir
class BaseOrganization:
@@ -59,6 +59,10 @@ class BaseOrganization:
self._tools_by_name: Dict[str, Callable] = {}
self._agents: Dict[str, Any] = {} # name -> pydantic-ai Agent
# 插件本地 SQLite 引擎(按需启用,调 init_local_db
self._engine: Any = None
self._session_maker: Any = None
# ─── 生命周期 ──────────────────────────────────────────────
async def setup(self) -> None:
@@ -74,6 +78,48 @@ class BaseOrganization:
self._stopped = True
if self._worker_task is not None:
self._worker_task.cancel()
if self._engine is not None:
try:
await self._engine.dispose()
except Exception:
self.logger.debug("engine dispose failed; ignored")
async def on_first_install(self) -> None:
"""安装期一次性钩子:插件首次落地时被调用一次。
典型用途:建数据表、写默认配置、提示用户去前端做后续配置。失败会抛错并让
plugin_manager 回滚(不写 marker,下次启动会重试)。子类按需覆盖;默认空实现。
"""
return None
async def init_local_db(self, base_classes: List[Type[Any]]) -> None:
"""建立插件私有 SQLite 引擎并按 ``base_classes`` 的元数据建表。
``base_classes`` 是插件自己定义的 ``DeclarativeBase`` 子类(每个插件用独立的 Base,
避免跟核心 PG 模型的元数据空间串场)。每次 setup 调用都安全:
``create_all`` 是幂等的,已存在的表不会被改动。
建立后 ``self._session_maker`` 可用于工具/API 内部按需 ``async with sm() as s``。
"""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
db_path = get_plugin_data_dir(self.name) / f"{self.name}.db"
url = f"sqlite+aiosqlite:///{db_path}"
self._engine = create_async_engine(url, future=True)
self._session_maker = async_sessionmaker(
self._engine, class_=AsyncSession, expire_on_commit=False
)
async with self._engine.begin() as conn:
for base in base_classes:
metadata = getattr(base, "metadata", None)
if metadata is None:
continue
await conn.run_sync(metadata.create_all)
self.logger.info(f"local sqlite ready: {db_path}")
# ─── 对外通道 ──────────────────────────────────────────────
@@ -326,29 +372,62 @@ class BaseOrganization:
全局工具白名单(``python_executor`` 等)也合并进来,给 agent 兜底。
"""
from pathlib import Path
import importlib
import importlib.util
import sys
import types
toolset_dir = Path(self.plugin_dir) / "toolset"
if toolset_dir.exists() and (toolset_dir / "manifest.json").exists():
with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f:
manifest = json.load(f)
# 跟 loader._import_entry_class 共用一条虚拟 package 链:
# ``_kilostar_plugin_<name>`` → ``.toolset``,让 ``from ._s3_common import ...``
# 这种相对导入能正常解析。
root_pkg = f"_kilostar_plugin_{self.name}"
tool_pkg = f"{root_pkg}.toolset"
if root_pkg not in sys.modules:
root_mod = types.ModuleType(root_pkg)
root_mod.__path__ = [str(Path(self.plugin_dir))]
sys.modules[root_pkg] = root_mod
if tool_pkg not in sys.modules:
pkg = types.ModuleType(tool_pkg)
pkg.__path__ = [str(toolset_dir)]
sys.modules[tool_pkg] = pkg
# 第一遍:把 toolset 目录下所有 .py 都按文件名注册成子模块,
# 让共享辅助模块(如 ``_s3_common``)先就位。
for py_path in sorted(toolset_dir.glob("*.py")):
if py_path.name == "__init__.py":
continue
sub_name = f"{tool_pkg}.{py_path.stem}"
if sub_name in sys.modules:
continue
spec = importlib.util.spec_from_file_location(sub_name, str(py_path))
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
mod.__package__ = tool_pkg
sys.modules[sub_name] = mod
try:
spec.loader.exec_module(mod)
except Exception as e:
self.logger.warning(f"failed to load tool module {py_path.name}: {e}")
sys.modules.pop(sub_name, None)
# 第二遍:按 manifest 列表挑出工具函数
for tool_def in manifest.get("tools", []):
tname = tool_def.get("name")
tfile = tool_def.get("file", f"{tname}.py")
if not tname:
continue
fpath = toolset_dir / tfile
if not fpath.exists():
self.logger.warning(f"tool file not found: {fpath}")
stem = Path(tfile).stem
sub_name = f"{tool_pkg}.{stem}"
mod = sys.modules.get(sub_name)
if mod is None:
self.logger.warning(f"tool module not loaded: {tfile}")
continue
module_name = f"data.plugin.{self.name}.toolset.{tname}"
spec = importlib.util.spec_from_file_location(module_name, str(fpath))
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
spec.loader.exec_module(mod)
func = getattr(mod, tname, None)
if callable(func):
self._tools_by_name[tname] = func
@@ -373,6 +452,11 @@ class BaseOrganization:
每个 agent 注入:
- 自己声明的 tools(从 ``_tools_by_name`` 取)
- 一个特殊 ``consult`` 工具(如果 peers 非空),用于跨 agent 协作
provider+model 的来源:
1. agents.json 里若已写死 ``model`` → 直接用(兼容老插件)
2. 否则按 ``(plugin_name, slot_name)`` 查 DB,拿用户在 Agent 设置页配置的
provider+model;查不到则跳过该 slot(日志 warning,让用户先去配置)
"""
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
@@ -381,10 +465,16 @@ class BaseOrganization:
factory = AgentFactory()
for adef in self.agents_config.agents:
provider = snapshot.providers.get(adef.model.provider_title)
provider_title, model_id = await self._resolve_slot_model(adef)
if not provider_title or not model_id:
self.logger.warning(
f"agent slot {adef.name!r}: provider/model 未配置(请在 Agent 设置页装配)"
)
continue
provider = snapshot.providers.get(provider_title)
if provider is None:
self.logger.warning(
f"provider {adef.model.provider_title!r} not found; agent {adef.name} skipped"
f"provider {provider_title!r} not found; agent {adef.name} skipped"
)
continue
tools = [
@@ -399,7 +489,7 @@ class BaseOrganization:
try:
agent = factory.create_agent(
provider=provider,
model_id=adef.model.model_id,
model_id=model_id,
output_type=str,
system_prompt=adef.system_prompt or f"You are {adef.role}.",
deps_type=type(None),
@@ -411,6 +501,26 @@ class BaseOrganization:
except Exception as e:
self.logger.warning(f"build agent {adef.name} failed: {e}")
async def _resolve_slot_model(self, adef: AgentDef) -> tuple[str, str]:
"""决定 slot 用哪个 provider+model。
优先静态绑定(向后兼容老插件),否则查 DB 中用户为该 slot 配置的值。
DB 不可用时返回空——构建侧据此跳过该 slot。
"""
if adef.model and adef.model.provider_title and adef.model.model_id:
return adef.model.provider_title, adef.model.model_id
try:
from kilostar.utils.ray_hook import ray_actor_hook
pg = ray_actor_hook("postgres_database").postgres_database
row = await pg.find_plugin_slot.remote(self.name, adef.name)
if row is None:
return "", ""
return getattr(row, "provider_title", "") or "", getattr(row, "model_id", "") or ""
except Exception as e:
self.logger.debug(f"slot model lookup failed (DB?): {e}")
return "", ""
def _make_consult_tool(self, adef: AgentDef):
"""为 agent 生成一个 ``consult(peer, question)`` 工具。
+75 -1
View File
@@ -82,6 +82,8 @@ def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[
"""形如 ``core.organization:DataCleaningOrg`` 的入口字符串解析。
``:`` 左边是相对插件根的模块路径(用 / 或 . 分隔均可),右边是类名。
会预先把插件根 + 入口模块所在子目录注册成虚拟 package,让相对导入
``from .db import ...``)能正常工作。
"""
if ":" not in entry:
raise ValueError(f"invalid entry {entry!r}: missing ':<ClassName>'")
@@ -91,11 +93,34 @@ def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[
if not file_path.exists():
raise FileNotFoundError(f"plugin {plugin_name} entry file not found: {file_path}")
module_name = f"data.plugin.{plugin_name}.{mod_path.replace('/', '.')}"
# 注册虚拟 root package(如 ``_kilostar_plugin_data_analytics``+ 入口所在子包
# (如 ``_kilostar_plugin_data_analytics.core``),这样 ``from .db import Base``
# 才能在 spec_from_file_location 加载的模块里正常解析。
import types as _types
root_pkg = f"_kilostar_plugin_{plugin_name}"
if root_pkg not in sys.modules:
root_mod = _types.ModuleType(root_pkg)
root_mod.__path__ = [str(plugin_dir)]
sys.modules[root_pkg] = root_mod
parts = mod_path.replace("/", ".").split(".")
cur_pkg = root_pkg
cur_dir = plugin_dir
for p in parts[:-1]:
cur_pkg = f"{cur_pkg}.{p}"
cur_dir = cur_dir / p
if cur_pkg not in sys.modules:
sub_mod = _types.ModuleType(cur_pkg)
sub_mod.__path__ = [str(cur_dir)]
sys.modules[cur_pkg] = sub_mod
module_name = f"{root_pkg}.{mod_path.replace('/', '.')}"
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load module {module_name}")
mod = importlib.util.module_from_spec(spec)
mod.__package__ = ".".join(module_name.split(".")[:-1])
sys.modules[module_name] = mod
spec.loader.exec_module(mod)
@@ -126,3 +151,52 @@ async def install_dependencies(deps_python: List[str]) -> None:
f"uv pip install failed (rc={proc.returncode}): {stderr.decode()}"
)
logger.info(f"installed deps: {deps_python}")
def discover_plugin_api(plugin_dir: Path, plugin_name: str) -> Any:
"""加载 ``<plugin_dir>/api.py``,返回模块的 ``router`` 属性(或 None)。
约定:插件如需暴露 HTTP 路由,在自己根目录写一个 ``api.py``,里面实例化
``router = APIRouter(...)`` 并按业务挂端点。主程序启动期统一以
``manifest.api_prefix`` 把它 include 到 FastAPI app。
"""
api_path = plugin_dir / "api.py"
if not api_path.exists():
return None
module_name = f"data.plugin.{plugin_name}.api"
spec = importlib.util.spec_from_file_location(module_name, str(api_path))
if spec is None or spec.loader is None:
logger.warning(f"plugin {plugin_name}: cannot load api.py at {api_path}")
return None
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
try:
spec.loader.exec_module(mod)
except Exception as e:
logger.warning(f"plugin {plugin_name}: api.py import failed: {e}")
return None
return getattr(mod, "router", None)
def collect_plugin_routers(plugin_root: Path) -> List[tuple]:
"""扫描所有插件,返回 ``[(api_prefix, router)]`` 列表。
用于 FastAPI 启动期统一挂载。纯文件扫描,不依赖任何 actor,避免启动顺序耦合。
无 ``api.py`` / 加载失败 / 缺 ``api_prefix`` 的插件被静默跳过。
"""
out: List[tuple] = []
for plugin_dir in discover_plugins(plugin_root):
try:
with open(plugin_dir / "manifest.json", "r", encoding="utf-8") as f:
manifest = OrgManifest.model_validate(json.load(f))
except Exception as e:
logger.warning(f"skip plugin {plugin_dir.name} (manifest invalid): {e}")
continue
if not manifest.api_prefix:
continue
router = discover_plugin_api(plugin_dir, manifest.name)
if router is None:
continue
out.append((manifest.api_prefix, router))
logger.info(f"discovered plugin router: {manifest.name} @ {manifest.api_prefix}")
return out
+73 -6
View File
@@ -21,7 +21,7 @@ from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
from kilostar.utils.logger import get_logger
from kilostar.utils.ray_compat import _STANDALONE, actor_class
from kilostar.utils.ray_hook import register_standalone
from kilostar.utils.settings import get_plugin_dir
from kilostar.utils.settings import get_plugin_data_dir, get_plugin_dir
logger = get_logger("plugin_manager")
@@ -84,15 +84,21 @@ class GlobalPluginManager:
# ─── 查询接口 ──────────────────────────────────────────────
def list_plugins(self) -> List[Dict[str, Any]]:
return [
{
out: List[Dict[str, Any]] = []
plugin_root = get_plugin_dir()
for name, info in self._orgs.items():
manifest = info.get("manifest", {}) or {}
ui = manifest.get("ui", {}) or {}
wc_manifest = plugin_root / name / "frontend" / "dist" / "wc-manifest.json"
out.append({
"name": name,
"display_name": info.get("display_name", name),
"description": info.get("description", ""),
"status": "running",
}
for name, info in self._orgs.items()
]
"has_ui": wc_manifest.exists(),
"icon": ui.get("icon"),
})
return out
def get_dispatch_tools(self) -> Dict[str, Any]:
"""返回所有 dispatch tools 的 {tool_name: callable} 字典。"""
@@ -111,6 +117,23 @@ class GlobalPluginManager:
# 实例化 organization actor
instance = cls(manifest_dict, agents_dict, dir_str)
# 一次性安装钩子:marker 文件不存在时调用 on_first_install
marker = get_plugin_data_dir(name) / ".installed"
first_install = not marker.exists()
if first_install:
try:
await instance.on_first_install()
except Exception as e:
logger.exception(
f"plugin {name} on_first_install failed: {e}; aborting install"
)
raise
marker.write_text(manifest.version, encoding="utf-8")
# 把 agents.json 的 slot 登记到 plugin_owned 表(best-effortDB 不可用时静默跳过)
await self._register_plugin_slots(name, agents_dict)
await instance.setup()
# 注册到 ray_actor_hook 命名空间
@@ -135,3 +158,47 @@ class GlobalPluginManager:
"actor_name": actor_name,
}
logger.info(f"loaded plugin: {name} (actor={actor_name})")
async def _register_plugin_slots(self, name: str, agents_dict: Dict[str, Any]) -> None:
"""把插件 agents.json 中的每个 agent upsert 为一行 plugin_owned slot。
只刷新 description/node_affinity;用户在前端配置的 provider/model 不被覆盖。
DB 不可用时静默跳过(standalone 启动早期 / 单测场景)。
"""
try:
from kilostar.utils.ray_hook import ray_actor_hook
pg = ray_actor_hook("postgres_database").postgres_database
for adef in agents_dict.get("agents", []):
slot_name = adef.get("name")
if not slot_name:
continue
description = adef.get("role") or adef.get("system_prompt") or slot_name
await pg.upsert_plugin_slot.remote(
plugin_name=name,
slot_name=slot_name,
description=description,
)
except Exception as e:
logger.debug(f"register_plugin_slots skipped for {name}: {e}")
async def cleanup_orphan_plugin_slots(self) -> None:
"""启动期兜底:DB 中存在但目录已不在的 plugin_owned slot 全部清掉。"""
try:
from kilostar.utils.ray_hook import ray_actor_hook
pg = ray_actor_hook("postgres_database").postgres_database
recorded: List[str] = await pg.list_plugin_owned_names.remote() or []
except Exception as e:
logger.debug(f"cleanup_orphan_plugin_slots skipped: {e}")
return
plugin_root = get_plugin_dir()
present = {p.name for p in plugin_root.iterdir() if p.is_dir()} if plugin_root.exists() else set()
for plugin_name in recorded:
if plugin_name not in present:
try:
n = await pg.delete_plugin_slots.remote(plugin_name)
logger.info(f"cleaned {n} orphan slots for missing plugin {plugin_name!r}")
except Exception as e:
logger.warning(f"failed to clean orphan slots for {plugin_name}: {e}")