Browse Source

feat(agents): 创建智能体实例时自动开辟标准工作目录

参照实际用出来的 Workspace/multi-reviewer67 布局, 创建实例时
(POST /agents 与 POST /agentpacks/{id}/create-agent 两个入口)
三个目录全空则自动开辟:

  <workspace_base>/<智能体名>/
  ├── data/                     source_dir — 投放输入材料
  ├── workspace/run_*/          run_dir 指向 <根>/workspace
  │                             (bare=True, run_* 直接建在其下)
  └── (最终交付物落根目录)        work_dir — 也是 Bash 会话 CWD

- config: AGENTPAAS_WORKSPACE_BASE, 默认 {data_dir}/Workspace
- engine/workspace_provision.py: slug 清洗(防路径逃逸)、重名 -2/-3
  后缀、失败返回空不拦创建
- 显式给了任一目录则完全尊重用户输入, 不自动补
- 产物从此不再藏进 agentpacks 安装目录, 每个智能体一个用户可见
  的独立文件夹

测试: test_workspace_provision.py 6 个(布局/重名/清洗/降级/两端点)
+ e2e 1 个; e2e fixture 隔离 workspace_base 防测试写真实目录。
全量 280 passed。live 验证: multi-reviewer67 同名创建目录结构与
参照逐字节一致, ollama 实跑 run_* 落 workspace/ 下

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju 2 tháng trước cách đây
mục cha
commit
181bff4527

+ 13 - 1
agentpaas/src/agentpaas/api/v1/agentpacks.py

@@ -400,6 +400,15 @@ async def create_agent_from_pack(
     config_json = json.dumps(config, ensure_ascii=False)
     config_hash = hashlib.sha256(config_json.encode()).hexdigest()
 
+    # 自动开辟工作目录(同 POST /agents 语义,参照 Workspace/
+    # multi-reviewer67 布局):三目录全空才自动建,显式输入优先。
+    work_dir, source_dir, run_dir = req.work_dir, req.source_dir, req.run_dir
+    if not (work_dir or source_dir or run_dir):
+        from agentpaas.engine.workspace_provision import provision_agent_workspace
+        dirs = provision_agent_workspace(req.name)
+        work_dir, source_dir, run_dir = (
+            dirs["work_dir"], dirs["source_dir"], dirs["run_dir"])
+
     agent_id = gen_id("ag_")
     now = now_utc()
     db = get_db()
@@ -413,7 +422,7 @@ async def create_agent_from_pack(
          json.dumps(req.tags),
          pack.path, pack.id,
          json.dumps(req.kb_ids), req.kb_search_mode,
-         req.work_dir, req.source_dir, req.run_dir, now, now),
+         work_dir, source_dir, run_dir, now, now),
     )
     db.execute(
         "INSERT INTO agent_versions "
@@ -430,4 +439,7 @@ async def create_agent_from_pack(
         "agent_id": agent_id,
         "name": req.name,
         "pack": {"id": pack.id, "version": pack.version, "name": pack.name},
+        "work_dir": work_dir,
+        "source_dir": source_dir,
+        "run_dir": run_dir,
     }

+ 14 - 1
agentpaas/src/agentpaas/api/v1/agents.py

@@ -131,6 +131,16 @@ async def create_agent(
             except Exception as e:
                 api_error(400, "INVALID_CONFIG", f"Instance config error: {e}")
 
+    # 自动开辟工作目录(参照 Workspace/multi-reviewer67 布局):
+    # 三个目录全空时建 <workspace_base>/<名>/{data,workspace};
+    # 用户显式给了任何一个则完全尊重输入。
+    work_dir, source_dir, run_dir = req.work_dir, req.source_dir, req.run_dir
+    if not (work_dir or source_dir or run_dir):
+        from agentpaas.engine.workspace_provision import provision_agent_workspace
+        dirs = provision_agent_workspace(req.name)
+        work_dir, source_dir, run_dir = (
+            dirs["work_dir"], dirs["source_dir"], dirs["run_dir"])
+
     now = now_utc()
     db.execute(
         "INSERT INTO agents (id, tenant_id, name, description, current_version, tags, environment, "
@@ -141,7 +151,7 @@ async def create_agent(
          json.dumps(req.tags), req.environment,
          agent_dir, req.agent_template, instance_dir,
          json.dumps(req.kb_ids), req.kb_search_mode,
-         req.work_dir, req.source_dir, req.run_dir, now, now)
+         work_dir, source_dir, run_dir, now, now)
     )
     db.execute(
         "INSERT INTO agent_versions (agent_id, version, config, config_hash, changelog, created_by, created_at) "
@@ -155,6 +165,9 @@ async def create_agent(
         "version": 1,
         "created_at": now,
         "endpoint": f"/api/v1/agents/{agent_id}",
+        "work_dir": work_dir,
+        "source_dir": source_dir,
+        "run_dir": run_dir,
     }
 
 

+ 8 - 0
agentpaas/src/agentpaas/config.py

@@ -207,6 +207,14 @@ class AgentPaaSConfig:
             "AGENTPAAS_AGENTPACKS_DIR",
             os.path.join(self.data_dir, "agentpacks")
         )
+        # 智能体工作目录基底:创建实例时自动开辟 <base>/<agent名>/
+        # (work_dir 根 + data/ 来源 + workspace/run_* 运行产物 — 参照
+        # Research-Vault/Workspace/multi-reviewer67 的布局)。
+        # 不在启动时 mkdir — 首次创建智能体时才建。
+        self.workspace_base: str = os.getenv(
+            "AGENTPAAS_WORKSPACE_BASE",
+            os.path.join(self.data_dir, "Workspace")
+        )
 
         # Auto-create essential directories on startup
         for _d in (self.data_dir, self.instances_dir, self.knowledge_bases_dir,

+ 65 - 0
agentpaas/src/agentpaas/engine/workspace_provision.py

@@ -0,0 +1,65 @@
+"""
+agentpaas.engine.workspace_provision — 创建智能体实例时自动开辟工作目录。
+
+布局参照 ``Research-Vault/Workspace/multi-reviewer67``(实际用出来的习惯):
+
+    <workspace_base>/<智能体名>/
+    ├── data/                       ← source_dir: 用户投放输入材料(论文/讲义)
+    ├── workspace/run_YYYYMMDD_*/   ← run_dir 指向 <根>/workspace,
+    │                                  _execute_agent bare=True 直接在其下建 run_*
+    └── (最终交付物直接落根目录)      ← work_dir: 报告/PDF 等,用户一眼可见;
+                                       也是 Bash 工具的会话 CWD
+
+好处:产物不再藏进安装包目录(agentpacks/<id>/<ver>/workspace/ 对
+非技术用户不可见),每个智能体一个独立文件夹,在访达里直接浏览。
+
+策略:仅当用户创建时**三个目录全部留空**才自动开辟;显式给了任何
+一个就完全尊重用户输入。失败返回全空——目录开辟绝不拦住智能体创建。
+"""
+from __future__ import annotations
+
+import logging
+import os
+import re
+from typing import Dict
+
+logger = logging.getLogger(__name__)
+
+
+def _slugify(name: str) -> str:
+    """目录名清洗:保留中英文/数字/._-,其余(含路径分隔符)替换为 -。"""
+    s = re.sub(r"[^\w一-鿿.\-]+", "-", (name or "").strip())
+    s = s.strip("-. ")
+    return s or "agent"
+
+
+def provision_agent_workspace(agent_name: str, base: str = "") -> Dict[str, str]:
+    """为新智能体开辟标准工作目录,返回 {work_dir, source_dir, run_dir}。
+
+    重名时追加 -2/-3… 后缀(每个实例独立文件夹,互不踩踏)。
+    任何异常 → 返回三个空串并告警(不抛)。
+    """
+    try:
+        if not base:
+            from agentpaas.config import settings
+            base = settings.workspace_base
+        slug = _slugify(agent_name)
+        root = os.path.join(base, slug)
+        n = 1
+        while os.path.exists(root):
+            n += 1
+            root = os.path.join(base, f"{slug}-{n}")
+        os.makedirs(os.path.join(root, "data"), exist_ok=True)
+        os.makedirs(os.path.join(root, "workspace"), exist_ok=True)
+        root = os.path.abspath(root)
+        return {
+            "work_dir": root,
+            "source_dir": os.path.join(root, "data"),
+            # run_dir 在 _execute_agent 里走 bare=True 分支(run_* 直接
+            # 建在该目录下、不再追加 workspace/),所以这里指到
+            # <根>/workspace —— 与参照布局 <根>/workspace/run_* 一致。
+            "run_dir": os.path.join(root, "workspace"),
+        }
+    except Exception as e:  # 开辟失败不拦创建
+        logger.warning(f"agent workspace provision failed for {agent_name!r}: {e}")
+        return {"work_dir": "", "source_dir": "", "run_dir": ""}

+ 24 - 0
tests/test_agentpack_e2e.py

@@ -72,6 +72,8 @@ def http_client(tmp_path, monkeypatch):
     packs_dir = tmp_path / "agentpacks"
     packs_dir.mkdir()
     monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
+    # 自动目录开辟隔离到 tmp(否则 create-agent 测试会写真实 Workspace/)
+    monkeypatch.setattr(settings, "workspace_base", str(tmp_path / "Workspace"))
 
     prev_db = _session_mod._db
     _session_mod._db = Database("sqlite:///:memory:")
@@ -459,3 +461,25 @@ def test_pack_update_refreshes_agents(http_client, tmp_path):
     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
+
+
+def test_create_agent_from_pack_auto_provisions_dirs(http_client, tmp_path):
+    """从包创建智能体也自动开辟 Workspace/<名>/{data,workspace} 目录。"""
+    client, key, packs_dir, pack = http_client
+    r = client.post(
+        f"/api/v1/agentpacks/{REVIEWER_ID}/create-agent",
+        json={"name": "目录开辟-审稿"},
+        headers=_auth(key),
+    )
+    assert r.status_code == 201, r.text[:300]
+    body = r.json()
+    assert body["work_dir"].endswith("目录开辟-审稿")
+    assert body["source_dir"] == os.path.join(body["work_dir"], "data")
+    assert body["run_dir"] == os.path.join(body["work_dir"], "workspace")
+    assert os.path.isdir(body["source_dir"])
+    assert os.path.isdir(body["run_dir"])
+
+    # GET 回读一致
+    g = client.get(f"/api/v1/agents/{body['agent_id']}", headers=_auth(key)).json()
+    assert g["work_dir"] == body["work_dir"]
+    assert g["run_dir"] == body["run_dir"]

+ 138 - 0
tests/test_workspace_provision.py

@@ -0,0 +1,138 @@
+"""
+tests/test_workspace_provision.py — 创建智能体实例时自动开辟工作目录。
+
+布局参照 Workspace/multi-reviewer67:
+  <base>/<名>/        work_dir(最终交付物 + Bash CWD)
+  <base>/<名>/data    source_dir
+  <base>/<名>/workspace  run_dir(bare 模式,run_* 直接建在其下)
+"""
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
+os.environ.setdefault("AGENTPAAS_TESTING", "1")
+
+
+# ── 单元:provision 函数 ─────────────────────────────────────────────────────
+
+def test_provision_layout(tmp_path):
+    from agentpaas.engine.workspace_provision import provision_agent_workspace
+
+    d = provision_agent_workspace("审稿助手", base=str(tmp_path))
+    root = os.path.join(str(tmp_path), "审稿助手")
+    assert d["work_dir"] == os.path.abspath(root)
+    assert d["source_dir"] == os.path.join(os.path.abspath(root), "data")
+    assert d["run_dir"] == os.path.join(os.path.abspath(root), "workspace")
+    assert os.path.isdir(d["source_dir"]) and os.path.isdir(d["run_dir"])
+
+
+def test_provision_duplicate_names_get_suffix(tmp_path):
+    from agentpaas.engine.workspace_provision import provision_agent_workspace
+
+    d1 = provision_agent_workspace("助手", base=str(tmp_path))
+    d2 = provision_agent_workspace("助手", base=str(tmp_path))
+    d3 = provision_agent_workspace("助手", base=str(tmp_path))
+    assert d1["work_dir"].endswith("助手")
+    assert d2["work_dir"].endswith("助手-2")
+    assert d3["work_dir"].endswith("助手-3")
+
+
+def test_provision_slug_sanitises_path_chars(tmp_path):
+    from agentpaas.engine.workspace_provision import provision_agent_workspace
+
+    d = provision_agent_workspace("../evil/name  v2", base=str(tmp_path))
+    # 不逃逸 base;路径分隔符被清洗
+    assert os.path.commonpath([d["work_dir"], str(tmp_path)]) == str(
+        os.path.abspath(str(tmp_path)))
+    assert "/evil/" not in d["work_dir"].replace(str(tmp_path), "")
+
+
+def test_provision_failure_returns_empty(monkeypatch):
+    from agentpaas.engine import workspace_provision as wp
+
+    monkeypatch.setattr(wp.os, "makedirs",
+                        lambda *a, **k: (_ for _ in ()).throw(OSError("ro")))
+    d = wp.provision_agent_workspace("x", base="/nonexistent-base")
+    assert d == {"work_dir": "", "source_dir": "", "run_dir": ""}
+
+
+# ── 集成:两个创建端点 ───────────────────────────────────────────────────────
+
+@pytest.fixture()
+def api_client(tmp_path, monkeypatch):
+    """TestClient + 内存 DB + 租户/key + workspace_base 指到 tmp。"""
+    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
+
+    ws_base = tmp_path / "Workspace"
+    monkeypatch.setattr(settings, "workspace_base", str(ws_base))
+
+    prev = _session_mod._db
+    _session_mod._db = Database("sqlite:///:memory:")
+    db = _session_mod._db
+    tid, uid = gen_id("tn_"), 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:*"]), now))
+    db.commit()
+    with TestClient(app) as client:
+        yield client, raw_key, str(ws_base), db
+    _session_mod._db = prev
+
+
+def _auth(k):
+    return {"Authorization": f"Bearer {k}"}
+
+
+_MIN_CONFIG = {"name": "目录测试", "type": "simple",
+               "model": {"name": "ollama/qwen2.5:7b"}, "systemPrompt": "t"}
+
+
+def test_create_agent_auto_provisions_dirs(api_client):
+    client, key, ws_base, db = api_client
+    r = client.post("/api/v1/agents", headers=_auth(key),
+                    json={"name": "目录测试", "config": _MIN_CONFIG})
+    assert r.status_code in (200, 201), r.text[:300]
+    body = r.json()
+    assert body["work_dir"].endswith("目录测试")
+    assert body["run_dir"] == os.path.join(body["work_dir"], "workspace")
+    assert os.path.isdir(body["source_dir"])
+
+    # DB 行一致
+    row = db.fetchone("SELECT work_dir, source_dir, run_dir FROM agents WHERE id=?",
+                      (body["agent_id"],))
+    assert row["work_dir"] == body["work_dir"]
+    assert row["run_dir"] == body["run_dir"]
+
+
+def test_create_agent_respects_explicit_dirs(api_client, tmp_path):
+    client, key, ws_base, db = api_client
+    my_dir = str(tmp_path / "my-own-dir")
+    os.makedirs(my_dir)
+    r = client.post("/api/v1/agents", headers=_auth(key),
+                    json={"name": "显式目录", "config": _MIN_CONFIG,
+                          "work_dir": my_dir})
+    assert r.status_code in (200, 201), r.text[:300]
+    body = r.json()
+    assert body["work_dir"] == my_dir
+    assert body["run_dir"] == ""  # 显式给了任一目录 → 其余不自动补
+    # 没有为它建 Workspace/<名> 目录
+    assert not os.path.exists(os.path.join(ws_base, "显式目录"))