Browse Source

feat(agentpack): zip 上传更新包 — 智能体自动切换到最新配置

POST /agentpacks/{id}/update-upload(loopback-only,同 install 安全态势):
1. peek_manifest_from_zip() 先校验不落盘 — zip 内 manifest.id 须与
   URL pack_id 一致,误传别的包直接 400
2. 安装新版本(同版本号覆盖重装,新版本号并存取最新)
3. refresh_agents=true(默认): 所有 agent_template == pack_id 的智能体
   用新 entrypoint 配置生成新 agent_version 并把 agent_dir 指向新安装
   路径 — 旧配置保留版本历史,可随时 /rollback

webui: 包卡片新增「更新」按钮(文件选择→上传→toast 报告版本变化与
被刷新的智能体数);手册 §8 补更新包说明

测试: e2e +3(loopback 门、peek id 校验、传播+回滚全流程)258 passed;
live 验证: v1 agent → 上传 v0.2.0 → 自动 v2 新 prompt 生效、错包被拒

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju 4 months ago
parent
commit
bb8c5a2667

+ 135 - 0
agentpaas/src/agentpaas/api/v1/agentpacks.py

@@ -27,6 +27,7 @@ from agentpaas.engine.agentpack_store import (
     install_from_zip,
     list_installed,
     get_installed,
+    peek_manifest_from_zip,
     uninstall,
     AgentPackInstallError,
 )
@@ -170,6 +171,140 @@ async def install_agentpack_upload(
     return {"ok": True, "installed": _pack_json(pack)}
 
 
+def _refresh_agents_from_pack(pack) -> list:
+    """把更新后的包 entrypoint 配置传播到所有由该包创建的智能体。
+
+    对每个 ``agents.agent_template == pack.id`` 的智能体:
+    - 用新 entrypoint 配置生成一个**新 agent_version**(保留版本历史,
+      可随时通过 /rollback 回到更新前的配置);
+    - ``agent_dir`` 指向新安装路径(子智能体 ./agents/x.yml 相对引用
+      跟着解析到新版本目录)。
+
+    Returns: [{agent_id, name, version}] 被刷新的智能体列表。
+    """
+    import hashlib
+    import json
+    import yaml
+    from agentpaas.db.session import get_db
+    from agentpaas.db.models import now_utc
+
+    try:
+        with open(pack.manifest.entrypoint_path(), encoding="utf-8") as f:
+            config = yaml.safe_load(f) or {}
+    except (OSError, yaml.YAMLError) as e:
+        raise HTTPException(status_code=500,
+                            detail=f"failed to read pack entrypoint: {e}")
+    # 子智能体相对路径在新包根下解析(同 create-agent 的 _config_dir 语义)
+    config.setdefault("_config_dir", pack.path)
+
+    config_json = json.dumps(config, ensure_ascii=False)
+    config_hash = hashlib.sha256(config_json.encode()).hexdigest()
+
+    db = get_db()
+    agents = db.fetchall(
+        "SELECT id, name, current_version FROM agents "
+        "WHERE agent_template = ? AND status = 'active'",
+        (pack.id,),
+    )
+    refreshed = []
+    now = now_utc()
+    for a in agents:
+        new_version = int(a["current_version"] or 0) + 1
+        db.execute(
+            "INSERT INTO agent_versions "
+            "(agent_id, version, config, config_hash, changelog, created_by, created_at) "
+            "VALUES (?, ?, ?, ?, ?, ?, ?)",
+            (a["id"], new_version, config_json, config_hash,
+             f"AgentPack updated: {pack.id} v{pack.version}", "system", now),
+        )
+        db.execute(
+            "UPDATE agents SET current_version = ?, agent_dir = ?, updated_at = ? "
+            "WHERE id = ?",
+            (new_version, pack.path, now, a["id"]),
+        )
+        refreshed.append({"agent_id": a["id"], "name": a["name"],
+                          "version": new_version})
+    db.commit()
+    return refreshed
+
+
+@router.post("/{pack_id}/update-upload")
+async def update_agentpack_upload(
+    pack_id: str,
+    request: Request,
+    file: UploadFile = File(..., description="新版 AgentPack .zip 文件"),
+    allow_shell: bool = False,
+    refresh_agents: bool = True,
+):
+    """上传 .zip 更新一个已安装的 AgentPack,并让基于它创建的智能体
+    跑在最新配置上。
+
+    流程:
+    1. 校验 zip 内 manifest.id 与 URL pack_id 一致(防误传别的包);
+    2. 安装(同版本号 → 覆盖重装;新版本号 → 并存,detail 取最新);
+    3. refresh_agents=true(默认)时,所有 agent_template == pack_id 的
+       智能体生成新 agent_version 指向新配置 —— 旧配置保留在版本历史,
+       可随时回滚。
+
+    Loopback-only —— 与 /install 相同的安全态势。
+    """
+    import os
+    import tempfile
+
+    _loopback_only(request)
+
+    existing = get_installed(settings.agentpacks_dir, pack_id)
+    if not existing:
+        raise HTTPException(status_code=404,
+                            detail=f"AgentPack {pack_id!r} 未安装,请先用「安装」上传")
+
+    if not (file.filename or "").lower().endswith(".zip"):
+        raise HTTPException(status_code=400, detail="只接受 .zip 文件")
+
+    try:
+        suffix = f"_{file.filename}" if file.filename else ".zip"
+        with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
+            tmp_path = tmp.name
+            tmp.write(await file.read())
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"上传文件写入失败: {e}")
+
+    try:
+        # 先窥探 manifest:包 id 必须匹配,否则拒绝(不落盘)
+        try:
+            incoming = peek_manifest_from_zip(tmp_path)
+        except (AgentPackInstallError, AgentPackError) as e:
+            raise HTTPException(status_code=400, detail=f"zip 校验失败: {e}")
+        if incoming.id != pack_id:
+            raise HTTPException(
+                status_code=400,
+                detail=f"包 id 不匹配:zip 内是 {incoming.id!r},"
+                       f"要更新的是 {pack_id!r}。如要安装新包请走「安装」入口。")
+
+        try:
+            pack = install_from_zip(
+                tmp_path,
+                settings.agentpacks_dir,
+                allow_shell=allow_shell,
+            )
+        except (AgentPackInstallError, AgentPackError) as e:
+            raise HTTPException(status_code=400, detail=str(e))
+
+        refreshed = _refresh_agents_from_pack(pack) if refresh_agents else []
+    finally:
+        try:
+            os.unlink(tmp_path)
+        except Exception:
+            pass
+
+    return {
+        "ok": True,
+        "installed": _pack_json(pack),
+        "previous_version": existing.version,
+        "refreshed_agents": refreshed,
+    }
+
+
 @router.delete("/{pack_id}", status_code=200)
 async def uninstall_agentpack(
     pack_id: str,

+ 20 - 0
agentpaas/src/agentpaas/engine/agentpack_store.py

@@ -74,6 +74,26 @@ def _find_pack_root(extracted_dir: str) -> str:
     )
 
 
+def peek_manifest_from_zip(zip_path: str) -> AgentPackManifest:
+    """校验并返回 zip 内的 manifest,但**不安装**。
+
+    用于「更新包」端点在落盘前确认 zip 里的包 id 与 URL 中的 pack_id
+    一致(防止把别的包误传成更新)。复用与安装相同的 zip-slip 防护
+    和 manifest 校验。Raises AgentPackInstallError / AgentPackError。
+    """
+    if not os.path.isfile(zip_path):
+        raise AgentPackInstallError(f"zip not found: {zip_path}")
+    if not zipfile.is_zipfile(zip_path):
+        raise AgentPackInstallError(f"not a valid zip file: {zip_path}")
+    tmp = tempfile.mkdtemp(prefix="agentpack_peek_")
+    try:
+        with zipfile.ZipFile(zip_path) as zf:
+            _safe_extract(zf, tmp)
+        return load_manifest(_find_pack_root(tmp))
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
 def install_from_zip(
     zip_path: str,
     agentpacks_dir: str,

+ 8 - 0
docs/DESKTOP_MANUAL_zh.md

@@ -276,6 +276,14 @@ curl -X POST http://127.0.0.1:8000/api/v1/traces/<run_id>/confirm \
 
 第三方包可通过「智能体包」页上传 zip 安装;声明 `shell: true` 的第三方包默认拒绝安装。
 
+**更新包**:拿到新版包 zip 后,在包卡片上点「**更新**」选择文件即可。系统会:
+
+1. 校验 zip 内的包 id 与当前包一致(误传别的包会被拒绝);
+2. 安装新版本(同版本号则覆盖重装);
+3. **自动把所有由该包创建的智能体切换到最新配置** —— 每个智能体生成一个新版本,旧配置保留在版本历史中,随时可在「编辑 → 版本历史」回滚。
+
+更新完成后的提示会告诉你有几个智能体被同步刷新。
+
 ---
 
 ## 9. 知识库:把你的文献变成可问答的资料库

+ 112 - 0
tests/test_agentpack_e2e.py

@@ -347,3 +347,115 @@ def test_run_agent_produces_review_report(tmp_path, http_client):
         f"review_report.md not found in {workspace}"
     assert os.path.isfile(os.path.join(workspace, "review_result.json")), \
         f"review_result.json not found in {workspace}"
+
+
+# ── Pack update (zip upload → agents on latest config) ───────────────────────
+# AUDIT 后新增功能: POST /agentpacks/{id}/update-upload。HTTP 层的 loopback
+# 门单测 + 核心传播逻辑直测(与 install 的测试策略一致)。
+
+def _pack_zip_with_prompt(tmp_path, pack_id: str, new_prompt: str,
+                          new_version: str = "") -> str:
+    """复制内置包源码,改 entrypoint 的 systemPrompt(和可选版本号),打成 zip。"""
+    import shutil
+    repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    src = os.path.join(repo_root, "agentexample", "agentpacks", pack_id)
+    if not os.path.isdir(src):
+        pytest.skip(f"built-in pack not found on disk: {src}")
+    work = str(tmp_path / "pack_v2_src")
+    shutil.copytree(src, work)
+
+    with open(os.path.join(work, "manifest.yml"), encoding="utf-8") as f:
+        manifest = yaml.safe_load(f)
+    if new_version:
+        manifest["version"] = new_version
+        with open(os.path.join(work, "manifest.yml"), "w", encoding="utf-8") as f:
+            yaml.safe_dump(manifest, f, allow_unicode=True)
+
+    entry = os.path.join(work, manifest["entrypoint"])
+    with open(entry, encoding="utf-8") as f:
+        cfg = yaml.safe_load(f)
+    cfg["systemPrompt"] = new_prompt
+    with open(entry, "w", encoding="utf-8") as f:
+        yaml.safe_dump(cfg, f, allow_unicode=True)
+
+    zip_path = str(tmp_path / "pack_v2.zip")
+    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+        for dirpath, _, filenames in os.walk(work):
+            for fname in filenames:
+                full = os.path.join(dirpath, fname)
+                zf.write(full, os.path.relpath(full, work))
+    return zip_path
+
+
+def test_update_upload_loopback_blocked(http_client, tmp_path):
+    """非 loopback 调 update-upload → 404(同 /install 的 oracle 防御)。"""
+    client, key, packs_dir, pack = http_client
+    zip_path = _pack_zip_with_prompt(tmp_path, REVIEWER_ID, "x")
+    with open(zip_path, "rb") as f:
+        r = client.post(
+            f"/api/v1/agentpacks/{REVIEWER_ID}/update-upload",
+            files={"file": ("pack.zip", f, "application/zip")},
+            headers=_auth(key),
+        )
+    assert r.status_code == 404
+
+
+def test_peek_manifest_id_check(tmp_path, http_client):
+    """peek_manifest_from_zip 返回正确 id(更新端点据此拒绝错包)。"""
+    from agentpaas.engine.agentpack_store import peek_manifest_from_zip
+    zip_path = _pack_zip_with_prompt(tmp_path, REVIEWER_ID, "NEW PROMPT")
+    m = peek_manifest_from_zip(zip_path)
+    assert m.id == REVIEWER_ID
+    # zip 不会被安装
+    assert not os.path.exists(
+        os.path.join(str(tmp_path), "agentpacks", REVIEWER_ID, "0.9.9"))
+
+
+def test_pack_update_refreshes_agents(http_client, tmp_path):
+    """更新包 → 由它创建的智能体生成新版本、配置换新、可回滚。"""
+    from agentpaas.api.v1.agentpacks import _refresh_agents_from_pack
+
+    client, key, packs_dir, pack = http_client
+
+    # 1. 从包创建智能体(HTTP)
+    r = client.post(
+        f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
+        json={"name": "更新测试-审稿"},
+        headers=_auth(key),
+    )
+    assert r.status_code == 201, r.text[:300]
+    agent_id = r.json()["agent_id"]
+
+    g0 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
+    old_prompt = g0["config"]["systemPrompt"]
+    assert g0["current_version"] == 1
+
+    # 2. 构造新版包(新版本号 + 新 systemPrompt)并安装
+    NEW_PROMPT = "你是更新后的审稿专家 v2。"
+    zip_path = _pack_zip_with_prompt(
+        tmp_path, REVIEWER_ID, NEW_PROMPT, new_version="0.9.9")
+    new_pack = _install_pack(packs_dir, zip_path)
+    assert new_pack.version == "0.9.9"
+
+    # 3. 传播到智能体
+    refreshed = _refresh_agents_from_pack(new_pack)
+    assert any(a["agent_id"] == agent_id and a["version"] == 2
+               for a in refreshed)
+
+    # 4. 智能体现在跑最新配置
+    g1 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
+    assert g1["current_version"] == 2
+    assert g1["config"]["systemPrompt"] == NEW_PROMPT
+    assert g1["config"]["systemPrompt"] != old_prompt
+    # agent_dir 跟到新版本安装路径
+    assert g1["agent_dir"].endswith("0.9.9")
+
+    # 5. 旧配置仍可回滚
+    rb = client.post(
+        f"/api/v1/agents/{agent_id}/rollback",
+        json={"target_version": 1},
+        headers=_auth(key),
+    )
+    assert rb.status_code == 200, rb.text[:300]
+    g2 = client.get(f"/api/v1/agents/{agent_id}", headers=_auth(key)).json()
+    assert g2["config"]["systemPrompt"] == old_prompt

+ 30 - 0
webui/src/api/agentpacks.ts

@@ -56,6 +56,14 @@ export interface UninstallResponse {
   removed_versions: number
 }
 
+export interface UpdatePackResponse {
+  ok: boolean
+  installed: AgentPack
+  previous_version: string
+  /** 被同步刷新到新配置的智能体(各自生成了新 agent_version) */
+  refreshed_agents: { agent_id: string; name: string; version: number }[]
+}
+
 export const agentpacksApi = {
   list: (): Promise<ListPacksResponse> =>
     api.get('/agentpacks'),
@@ -85,6 +93,28 @@ export const agentpacksApi = {
     return res.json()
   },
 
+  /** 上传 .zip 更新已安装的包,并把新配置传播到由它创建的所有智能体
+   *  (每个智能体生成新 agent_version,可回滚)。Loopback-only。 */
+  updateUpload: async (
+    packId: string, file: File, allowShell = false,
+  ): Promise<UpdatePackResponse> => {
+    const apiKey = useAppStore.getState().apiKey
+    const form = new FormData()
+    form.append('file', file)
+    if (allowShell) form.append('allow_shell', 'true')
+    const res = await fetch(`/api/v1/agentpacks/${packId}/update-upload`, {
+      method: 'POST',
+      headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
+      body: form,
+    })
+    if (!res.ok) {
+      let msg = res.statusText
+      try { msg = (await res.json())?.detail ?? msg } catch { /* ignore */ }
+      throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
+    }
+    return res.json()
+  },
+
   uninstall: (packId: string, version?: string): Promise<UninstallResponse> =>
     api.delete(`/agentpacks/${packId}${version ? `?version=${version}` : ''}`),
 

+ 45 - 0
webui/src/pages/AgentPacks.tsx

@@ -454,11 +454,17 @@ function PackCard({
   pack,
   onUninstall,
   onCreateAgent,
+  onUpdateZip,
+  updating,
 }: {
   pack: AgentPack
   onUninstall: () => void
   onCreateAgent: () => void
+  /** 选择新版 .zip 后回调 — 更新包并把新配置同步到由它创建的智能体 */
+  onUpdateZip: (file: File) => void
+  updating?: boolean
 }) {
+  const updateInputRef = useRef<HTMLInputElement>(null)
   return (
     <Card className="flex flex-col gap-3 hover:shadow-md transition-shadow">
       {/* Header */}
@@ -516,6 +522,27 @@ function PackCard({
         >
           创建智能体
         </Button>
+        <Button
+          variant="secondary"
+          size="sm"
+          icon={<Upload size={13} />}
+          disabled={updating}
+          onClick={() => updateInputRef.current?.click()}
+          title="上传新版 .zip 更新此包,并让基于它创建的智能体跑最新配置"
+        >
+          {updating ? '更新中…' : '更新'}
+        </Button>
+        <input
+          ref={updateInputRef}
+          type="file"
+          accept=".zip"
+          className="hidden"
+          onChange={(e) => {
+            const f = e.target.files?.[0]
+            if (f) onUpdateZip(f)
+            e.target.value = ''  // 允许重复选择同一文件
+          }}
+        />
       </div>
     </Card>
   )
@@ -544,6 +571,22 @@ export default function AgentPacks() {
     onError: (e: any) => toast.error(e.message ?? '卸载失败'),
   })
 
+  const updateMut = useMutation({
+    mutationFn: ({ packId, file }: { packId: string; file: File }) =>
+      agentpacksApi.updateUpload(packId, file),
+    onSuccess: (res) => {
+      const n = res.refreshed_agents.length
+      toast.success(
+        `已更新「${res.installed.name}」 v${res.previous_version} → v${res.installed.version}` +
+        (n > 0 ? `,${n} 个智能体已切换到最新配置(可在版本历史回滚)` : ''),
+        { duration: 6000 },
+      )
+      qc.invalidateQueries({ queryKey: ['agentpacks'] })
+      qc.invalidateQueries({ queryKey: ['agents'] })
+    },
+    onError: (e: any) => toast.error(e.message ?? '更新失败'),
+  })
+
   const packs = data?.agentpacks ?? []
 
   return (
@@ -622,6 +665,8 @@ export default function AgentPacks() {
                 }
               }}
               onCreateAgent={() => setCreatePack(pack)}
+              onUpdateZip={(file) => updateMut.mutate({ packId: pack.id, file })}
+              updating={updateMut.isPending && updateMut.variables?.packId === pack.id}
             />
           ))}
         </div>