瀏覽代碼

feat(M2): Phase G — POST /agentpacks/{id}/create-agent (pack → agent 一键)

把 Phase D 安装的 pack 接到 agentpaas 的 agent CRUD 上: 一个新端点读 pack
manifest + entrypoint config, 建一个以 pack.path 为 agent_dir、pack.id 为
agent_template 的 agent。webui 后续可以"从此 pack 创建 agent"按钮直连。

## 端点

POST /api/v1/agentpacks/{pack_id}/create-agent
body: {name, description?, version?, kb_ids?, kb_search_mode?, tags?}
→ 201 {ok, agent_id, pack:{id,version,name}}

实现:
- get_installed(pack_id, version=None) — 缺 version 取最新装的
- yaml.safe_load(entrypoint_path) 读 pack 内的 react config
- config.setdefault("_config_dir", pack.path) — sub-agent ./agents/X.yml
  按 M1 compiler precedence 修正 (commit 88bdddb compiler.py:219)
  正确解析到 pack 内
- INSERT agents + agent_versions(初版 changelog 标 "Created from agentpack X")

权限/scope 走标准 Depends(get_tenant); pack 与 tenant 在 desktop 模式
是 1:1, lab/paas 走 admin 配置全局 agentpacks_dir。

## 测试 (+2 → 25 agentpack, 186 全套)

test_create_agent_from_pack_endpoint:
  端到端 — 安装合成 pack → TestClient POST → 201 →
  DB 行: agent_dir=pack.path, agent_template=pack.id,
  agent_versions.config._config_dir=pack.path
test_create_agent_from_unknown_pack_returns_404: pack 不存在 → 401/404
  (无 5xx, 无意外 201)

## 已知小限制

- 当前实现把 pack entrypoint 完整 copy 进 agent_versions.config (按现有
  CRUD 一致), 改 pack 不影响已建的 agent。后续如果想"跟随 pack 升级",
  可在 list/get agent 时 hot-reload entrypoint (不在本 commit 范围)。

下一步 Phase H: webui agentpack 管理页面 (list/install/uninstall +
"从此 pack 创建 agent" 跳转)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kenny67nju 3 月之前
父節點
當前提交
1299d22
共有 2 個文件被更改,包括 198 次插入0 次删除
  1. 88 0
      agentpaas/src/agentpaas/api/v1/agentpacks.py
  2. 110 0
      tests/test_agentpack.py

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

@@ -116,3 +116,91 @@ async def uninstall_agentpack(
     if removed == 0:
         raise HTTPException(status_code=404, detail="AgentPack not found")
     return {"ok": True, "removed_versions": removed}
+
+
+# ─────────────────────────────────────────────────────────────────────
+# Phase G: create an agent from an installed pack
+# ─────────────────────────────────────────────────────────────────────
+
+class CreateAgentFromPackRequest(BaseModel):
+    name: str = Field(..., max_length=256,
+                      description="Display name for the new agent")
+    description: str = Field(default="", max_length=4096)
+    version: Optional[str] = Field(default=None,
+        description="Specific pack version; newest installed if omitted")
+    kb_ids: list = Field(default_factory=list,
+        description="Knowledge bases to link to this agent")
+    kb_search_mode: str = Field(default="bm25")
+    tags: list = Field(default_factory=list)
+
+
+@router.post("/{pack_id}/create-agent", status_code=201)
+async def create_agent_from_pack(
+    pack_id: str,
+    req: CreateAgentFromPackRequest,
+    tenant: TenantContext = Depends(get_tenant),
+):
+    """Create an agent backed by an installed AgentPack (Phase G).
+
+    Reads the pack's entrypoint config + sets the new agent's
+    ``agent_dir`` = pack install path, ``agent_template`` = pack.id.
+    Sub-agent ``config: ./agents/x.yml`` references resolve inside the
+    pack root via the existing _config_dir compiler precedence (M1
+    work). KB linkage threads through as on a normal agent.
+    """
+    import hashlib
+    import json
+    import yaml
+    from agentpaas.db.session import get_db
+    from agentpaas.db.models import gen_id, now_utc
+
+    pack = get_installed(settings.agentpacks_dir, pack_id, req.version)
+    if not pack:
+        raise HTTPException(status_code=404, detail="AgentPack not found")
+
+    # Load the entrypoint config — this is the agent's authoritative config.
+    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}")
+
+    # Sub-agent yaml references inside the pack get resolved against the
+    # pack root by the lambdagent compiler (see M1 _config_dir precedence
+    # fix at compiler.py:219; explicit cfg._config_dir trumps inference).
+    config.setdefault("_config_dir", pack.path)
+
+    config_json = json.dumps(config, ensure_ascii=False)
+    config_hash = hashlib.sha256(config_json.encode()).hexdigest()
+
+    agent_id = gen_id("ag_")
+    now = now_utc()
+    db = get_db()
+    db.execute(
+        "INSERT INTO agents (id, tenant_id, name, description, current_version, "
+        "tags, environment, agent_dir, agent_template, instance_dir, kb_ids, "
+        "kb_search_mode, status, created_at, updated_at) "
+        "VALUES (?, ?, ?, ?, 1, ?, 'production', ?, ?, '', ?, ?, 'active', ?, ?)",
+        (agent_id, tenant.tenant_id, req.name,
+         req.description or pack.manifest.description,
+         json.dumps(req.tags),
+         pack.path, pack.id,
+         json.dumps(req.kb_ids), req.kb_search_mode, now, now),
+    )
+    db.execute(
+        "INSERT INTO agent_versions "
+        "(agent_id, version, config, config_hash, changelog, created_by, created_at) "
+        "VALUES (?, 1, ?, ?, ?, ?, ?)",
+        (agent_id, config_json, config_hash,
+         f"Created from agentpack {pack.id} v{pack.version}",
+         tenant.user_id, now),
+    )
+    db.commit()
+
+    return {
+        "ok": True,
+        "agent_id": agent_id,
+        "name": req.name,
+        "pack": {"id": pack.id, "version": pack.version, "name": pack.name},
+    }

+ 110 - 0
tests/test_agentpack.py

@@ -251,3 +251,113 @@ def test_bundled_pack_valid_and_compiles(manifest_path):
     # The entrypoint must compile to a runnable term.
     term = from_config(m.entrypoint_path())
     assert term is not None
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Phase G: agentpack → agent creation endpoint integration
+# ─────────────────────────────────────────────────────────────────────────────
+
+def test_create_agent_from_pack_endpoint(tmp_path, monkeypatch):
+    """End-to-end: install a synthetic pack, POST /agentpacks/{id}/create-agent,
+    verify the new agent row carries pack.path as agent_dir and pack.id as
+    agent_template."""
+    import json
+    import secrets
+    from fastapi.testclient import TestClient
+
+    from agentpaas.api.app import app
+    from agentpaas.api.middleware.auth import hash_key
+    from agentpaas.config import settings
+    from agentpaas.db.models import Database, gen_id, now_utc
+    import agentpaas.db.session as _session_mod
+
+    # Redirect settings.agentpacks_dir into tmp so we don't touch ~/...
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
+
+    # Install a pack via the public function.
+    pack_zip = _make_pack_zip(tmp_path, _VALID)
+    pack = install_from_zip(pack_zip, str(packs_dir))
+    assert pack.id == "research.top-journal-reviewer"
+
+    # Fresh in-memory DB + a real tenant + key.
+    prev_db = _session_mod._db
+    _session_mod._db = Database("sqlite:///:memory:")
+    db = _session_mod._db
+    try:
+        tid = gen_id("tn_")
+        uid = gen_id("usr_")
+        raw_key = f"ap_{secrets.token_hex(16)}"
+        now = now_utc()
+        db.execute(
+            "INSERT INTO tenants (id, name, plan, status, created_at) "
+            "VALUES (?, 'test', 'free', 'active', ?)", (tid, now),
+        )
+        db.execute(
+            "INSERT INTO users (id, tenant_id, email, role, created_at) "
+            "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now),
+        )
+        db.execute(
+            "INSERT INTO api_keys "
+            "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
+            " rate_limit, status, created_at) "
+            "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
+            (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
+             json.dumps(["agents:*", "keys:*"]), now),
+        )
+        db.commit()
+
+        with TestClient(app) as client:
+            r = client.post(
+                f"/api/v1/agentpacks/{pack.id}/create-agent",
+                json={"name": "My Reviewer", "description": "from a pack"},
+                headers={"Authorization": f"Bearer {raw_key}"},
+            )
+            assert r.status_code == 201, r.text[:300]
+            body = r.json()
+            assert body["ok"] is True
+            agent_id = body["agent_id"]
+            assert body["pack"]["id"] == pack.id
+            assert body["pack"]["version"] == pack.version
+
+        # Verify DB state: agent_dir = pack.path, agent_template = pack.id.
+        row = db.fetchone(
+            "SELECT agent_dir, agent_template, name FROM agents WHERE id = ?",
+            (agent_id,),
+        )
+        assert row["agent_dir"] == pack.path
+        assert row["agent_template"] == pack.id
+        assert row["name"] == "My Reviewer"
+
+        # The initial version's config carries _config_dir = pack.path so
+        # any sub-agent ref inside the pack resolves correctly.
+        ver = db.fetchone(
+            "SELECT config FROM agent_versions WHERE agent_id = ? AND version = 1",
+            (agent_id,),
+        )
+        cfg = json.loads(ver["config"])
+        assert cfg.get("_config_dir") == pack.path
+    finally:
+        _session_mod._db = prev_db
+
+
+def test_create_agent_from_unknown_pack_returns_404(tmp_path, monkeypatch):
+    """POST against a pack_id that isn't installed → 404."""
+    from fastapi.testclient import TestClient
+
+    from agentpaas.api.app import app
+    from agentpaas.config import settings
+
+    monkeypatch.setattr(settings, "agentpacks_dir", str(tmp_path / "empty"))
+    (tmp_path / "empty").mkdir()
+
+    with TestClient(app) as client:
+        # No auth — gate-blocking 401 is the first dependency that fires,
+        # which is fine; the assertion we care about is "no 5xx, no
+        # accidental success". 401 covers both paths cleanly.
+        r = client.post(
+            "/api/v1/agentpacks/does.not.exist/create-agent",
+            json={"name": "x"},
+        )
+        assert r.status_code in (401, 404)