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
+35
View File
@@ -109,6 +109,17 @@ app.include_router(plugin_router) # plugin路径
app.include_router(task_router) # 短任务路径
# 重型插件:纯文件扫描挂载各插件自带的 ``api.py`` router(不依赖 actor 启动顺序)
try:
from kilostar.plugin_runtime.loader import collect_plugin_routers
from kilostar.utils.settings import get_plugin_dir as _get_plugin_dir
for _prefix, _plugin_router in collect_plugin_routers(_get_plugin_dir()):
app.include_router(_plugin_router, prefix=_prefix)
except Exception as _e:
_api_logger.warning(f"failed to mount plugin routers: {_e}")
@app.exception_handler(BusinessError)
async def business_error_handler(request: Request, exc: BusinessError):
"""业务可预期错误:按 ``http_status`` 返回 4xx,附 ``code`` + 异常消息。"""
@@ -149,6 +160,30 @@ base_dir = os.path.dirname(
)
frontend_dir = os.path.join(base_dir, "frontend", "dist")
# 重型插件 UI:扫描 ``data/plugin/*/frontend/dist/``,按目录名挂成静态资源
# /plugin-ui/<name>/ → <plugin>/frontend/dist/)。html=True 让目录访问回退到 index.html
# (单页应用风格),但实际我们靠 wc-manifest.json + import() 加载,不依赖该回退。
try:
from kilostar.utils.settings import get_plugin_dir as _get_plugin_dir_for_ui
_plugin_root_for_ui = _get_plugin_dir_for_ui()
if _plugin_root_for_ui.exists():
for _p in _plugin_root_for_ui.iterdir():
if not _p.is_dir():
continue
_dist = _p / "frontend" / "dist"
if not _dist.is_dir():
continue
app.mount(
f"/plugin-ui/{_p.name}",
StaticFiles(directory=str(_dist), html=True),
name=f"plugin_ui_{_p.name}",
)
_api_logger.info(f"mounted plugin UI: /plugin-ui/{_p.name}{_dist}")
except Exception as _e:
_api_logger.warning(f"failed to mount plugin UIs: {_e}")
if os.path.exists(frontend_dir):
app.mount(
"/assets",
+25 -6
View File
@@ -192,24 +192,34 @@ async def create_worker_individual(
async def get_worker_individual_list(
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""列出当前登录用户名下的全部 Worker Agent"""
"""列出当前登录用户名下的全部 Worker Agent,并附加所有 plugin_owned slot。
plugin_owned slot 是插件登记的"占位 agent",所有用户共享同一份配置,
在前端展示时会用徽标标记,并允许任何登录用户装配 provider/model。
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
workers = await postgres_database.get_worker_individual_list.remote(
owner_id=token_data.user_id
)
return {"workers": workers}
) or []
all_workers = await postgres_database.get_all_worker_individual.remote() or []
seen_ids = {w.agent_id for w in workers}
plugin_slots = [
w for w in all_workers
if getattr(w, "plugin_owned", None) and w.agent_id not in seen_ids
]
return {"workers": list(workers) + plugin_slots}
@agent_router.get("/worker/{agent_id}")
async def get_worker_individual(
agent_id: str, token_data: TokenData = Depends(Accessor.get_current_user)
):
"""按 ``agent_id`` 查询 Worker Agent;非本人的 Agent 返回 403。"""
"""按 ``agent_id`` 查询 Worker Agent;非本人的 Agent 返回 403plugin_owned slot 例外)"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
if not worker:
raise HTTPException(status_code=404, detail="Agent not found")
if worker.owner_id != token_data.user_id:
if not getattr(worker, "plugin_owned", None) and worker.owner_id != token_data.user_id:
raise HTTPException(
status_code=403, detail="Forbidden: You do not own this agent"
)
@@ -227,7 +237,8 @@ async def update_worker_individual(
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
if not worker:
raise HTTPException(status_code=404, detail="Agent not found")
if worker.owner_id != token_data.user_id:
# plugin_owned slot:任何登录用户都能装配 provider/model;普通 worker 仅 owner 可改
if not getattr(worker, "plugin_owned", None) and worker.owner_id != token_data.user_id:
raise HTTPException(
status_code=403, detail="Forbidden: You do not own this agent"
)
@@ -243,6 +254,14 @@ async def update_worker_individual(
except Exception:
pass
# plugin_owned 时顺带触发对应插件的 reload,让新 provider/model 立刻生效
if getattr(worker, "plugin_owned", None):
try:
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
await pm.reload.remote(worker.plugin_owned)
except Exception:
pass
return {"message": "success", "worker": updated_worker}
+56
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.settings import get_plugin_dir
plugin_router = APIRouter(prefix="/api/v1/plugin", tags=["plugin"])
@@ -106,3 +109,56 @@ async def reload_plugin(
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
await pm.reload.remote(name)
return {"status": "ok", "name": name}
@plugin_router.get("/{name}/ui-manifest")
async def ui_manifest(
name: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""返回 Web Component 注入所需的元数据。
读插件 build 产物 ``<plugin>/frontend/dist/wc-manifest.json``,把 ``js`` / ``css`` 路径
转成绝对静态路径(与 ``/plugin-ui/<name>/`` 静态挂载对齐)。dist 不存在 → 404。
"""
plugin_dir = get_plugin_dir() / name
if not plugin_dir.is_dir():
raise HTTPException(404, f"plugin {name!r} not found")
wc_path = plugin_dir / "frontend" / "dist" / "wc-manifest.json"
if not wc_path.is_file():
raise HTTPException(404, f"plugin {name!r} has no built UI")
try:
wc = json.loads(wc_path.read_text(encoding="utf-8"))
except Exception as e:
raise HTTPException(500, f"invalid wc-manifest.json: {e}")
js_rel = wc.get("js")
if not js_rel or not isinstance(js_rel, str):
raise HTTPException(500, "wc-manifest.json missing 'js'")
tag = wc.get("tag")
if not tag or not isinstance(tag, str):
raise HTTPException(500, "wc-manifest.json missing 'tag'")
base_url = f"/plugin-ui/{name}"
css_rel = wc.get("css") or []
if isinstance(css_rel, str):
css_rel = [css_rel]
# 顺便把 manifest.json 的展示元数据带回前端,少一次往返
display_name = name
icon = None
try:
manifest_data = json.loads((plugin_dir / "manifest.json").read_text(encoding="utf-8"))
display_name = manifest_data.get("display_name") or name
icon = (manifest_data.get("ui") or {}).get("icon")
except Exception:
pass
return {
"name": name,
"tag": tag,
"js": f"{base_url}/{js_rel.lstrip('/')}",
"css": [f"{base_url}/{c.lstrip('/')}" for c in css_rel],
"display_name": display_name,
"icon": icon,
}
@@ -48,6 +48,9 @@ class BaseIndividualModel(BaseDataModel):
nullable=True,
index=True,
)
plugin_owned: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, index=True
)
__mapper_args__ = {"polymorphic_on": "agent_type", "polymorphic_identity": "base"}
@@ -18,9 +18,10 @@ from kilostar.core.postgres_database.model.individual import (
OrdinaryIndividualModel,
SpecialIndividualModel,
)
from sqlalchemy import select
from sqlalchemy import select, and_
from typing import List, Optional
from kilostar.core.postgres_database.database_exception import database_exception
from kilostar.utils.error import BusinessError
from ulid import ULID
@@ -95,7 +96,11 @@ class IndividualDatabase:
@database_exception
async def delete_worker_individual(self, agent_id: str) -> bool:
"""删除 Individual;不存在返回 False,删除成功返回 True。"""
"""删除 Individual;不存在返回 False,删除成功返回 True。
``plugin_owned`` 不为空时拒绝删除(插件 agent 由插件生命周期管理,
要清理该插件目录后由启动期兜底逻辑收回)。
"""
async with self.async_session_maker() as session:
statement = select(BaseIndividualModel).where(
BaseIndividualModel.agent_id == agent_id
@@ -104,6 +109,11 @@ class IndividualDatabase:
individual = results.scalar_one_or_none()
if not individual:
return False
if individual.plugin_owned:
raise BusinessError(
f"agent {agent_id} 由插件 {individual.plugin_owned} 拥有,"
f"不可删除(请改卸载插件)"
)
await session.delete(individual)
await session.commit()
return True
@@ -115,3 +125,85 @@ class IndividualDatabase:
statement = select(BaseIndividualModel)
results = await session.execute(statement)
return list(results.scalars().all())
# ─── plugin_owned slot 专用 ─────────────────────────────────────
@database_exception
async def find_plugin_slot(self, plugin_name: str, slot_name: str):
"""按 ``(plugin_owned, agent_name)`` 查 slot;返回 ORM 对象或 None。"""
async with self.async_session_maker() as session:
stmt = select(BaseIndividualModel).where(
and_(
BaseIndividualModel.plugin_owned == plugin_name,
BaseIndividualModel.agent_name == slot_name,
)
)
return (await session.execute(stmt)).scalar_one_or_none()
@database_exception
async def upsert_plugin_slot(
self,
plugin_name: str,
slot_name: str,
description: str,
owner_id: str = "system",
node_affinity: str = "cpu",
):
"""插件安装期登记一个 agent slot。
slot 的 provider/model 留空,等用户在前端 Agent 页面装配。已存在则
只刷新 description/node_affinity(用户自己装配的 provider+model 不被覆盖)。
"""
async with self.async_session_maker() as session:
stmt = select(BaseIndividualModel).where(
and_(
BaseIndividualModel.plugin_owned == plugin_name,
BaseIndividualModel.agent_name == slot_name,
)
)
existing = (await session.execute(stmt)).scalar_one_or_none()
if existing is not None:
existing.description = description
existing.node_affinity = node_affinity
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing
row = BaseIndividualModel(
agent_id=str(ULID()),
agent_name=slot_name,
description=description,
provider_title="",
model_id="",
owner_id=owner_id,
agent_type="base",
node_affinity=node_affinity,
plugin_owned=plugin_name,
)
session.add(row)
await session.commit()
await session.refresh(row)
return row
@database_exception
async def list_plugin_owned_names(self) -> List[str]:
"""返回当前 DB 中所有出现过的 ``plugin_owned`` 值(去重)。"""
async with self.async_session_maker() as session:
stmt = select(BaseIndividualModel.plugin_owned).where(
BaseIndividualModel.plugin_owned.is_not(None)
).distinct()
return [r for (r,) in (await session.execute(stmt)).all() if r]
@database_exception
async def delete_plugin_slots(self, plugin_name: str) -> int:
"""删掉 ``plugin_owned == plugin_name`` 的所有 slot;返回被删条数。"""
async with self.async_session_maker() as session:
stmt = select(BaseIndividualModel).where(
BaseIndividualModel.plugin_owned == plugin_name
)
rows = list((await session.execute(stmt)).scalars().all())
for row in rows:
await session.delete(row)
await session.commit()
return len(rows)
@@ -246,6 +246,36 @@ class PostgresDatabase:
await self.ready_event.wait()
return await self._individual_database.get_all_worker_individual()
# plugin_owned slot 专用门面方法
async def find_plugin_slot(self, plugin_name: str, slot_name: str):
"""按 (plugin, slot) 查找已登记的插件 agent slot。"""
await self.ready_event.wait()
return await self._individual_database.find_plugin_slot(plugin_name, slot_name)
async def upsert_plugin_slot(
self,
plugin_name: str,
slot_name: str,
description: str,
owner_id: str = "system",
node_affinity: str = "cpu",
):
"""插件安装期登记 agent slot;用户自配 provider/model 不被覆盖。"""
await self.ready_event.wait()
return await self._individual_database.upsert_plugin_slot(
plugin_name, slot_name, description, owner_id, node_affinity
)
async def list_plugin_owned_names(self):
"""枚举当前数据库中所有 plugin_owned 值(去重)。"""
await self.ready_event.wait()
return await self._individual_database.list_plugin_owned_names()
async def delete_plugin_slots(self, plugin_name: str) -> int:
"""删掉某插件登记的全部 slot,返回被删条数。"""
await self.ready_event.wait()
return await self._individual_database.delete_plugin_slots(plugin_name)
# Workflow Database Methods
async def create_workflow(
self, trace_id: str, user_id: str, title: str, command: str
+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}")
+13
View File
@@ -86,6 +86,19 @@ def get_toolset_dir() -> "pathlib.Path":
return project_root / "data" / "toolset"
def get_plugin_data_dir(plugin_name: str) -> "pathlib.Path":
"""返回单个插件的私有数据目录:``<plugin_dir>/<name>/_data/``。
放 SQLite 文件、安装 marker、本地缓存等运行时状态——跟随插件代码同根目录,
用户备份/迁移整个插件文件夹时数据自动跟着走。目录不存在时自动创建。
"""
import pathlib
target = get_plugin_dir() / plugin_name / "_data"
target.mkdir(parents=True, exist_ok=True)
return target
def get_artifact_dir() -> "pathlib.Path":
"""返回工作流产物(agent 通过 send_file 推送的文件)存放根目录。