存档
This commit is contained in:
@@ -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
@@ -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 返回 403(plugin_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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)`` 工具。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-effort,DB 不可用时静默跳过)
|
||||
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}")
|
||||
|
||||
@@ -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 推送的文件)存放根目录。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user