test_instance.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. """
  2. Tests for Agent Instance mechanism.
  3. Tests cover:
  4. 1. _deep_merge() — dict merging semantics
  5. 2. create_instance() — directory and file creation
  6. 3. load_instance() — merge agent template + instance overrides
  7. 4. load_instance_from_dirs() — two-dir loading
  8. 5. Multiple instances from same template
  9. """
  10. import json
  11. import os
  12. import shutil
  13. import tempfile
  14. import pytest
  15. import yaml
  16. from agentpaas.engine.instance import (
  17. _deep_merge,
  18. create_instance,
  19. load_instance,
  20. load_instance_from_dirs,
  21. )
  22. @pytest.fixture
  23. def tmpdir():
  24. d = tempfile.mkdtemp(prefix="test_instance_")
  25. yield d
  26. shutil.rmtree(d, ignore_errors=True)
  27. def _write_yaml(path, data):
  28. os.makedirs(os.path.dirname(path), exist_ok=True)
  29. with open(path, "w") as f:
  30. yaml.dump(data, f, allow_unicode=True)
  31. # ============================================================
  32. # 1. _deep_merge
  33. # ============================================================
  34. class TestDeepMerge:
  35. def test_simple_override(self):
  36. base = {"a": 1, "b": 2}
  37. over = {"b": 3, "c": 4}
  38. assert _deep_merge(base, over) == {"a": 1, "b": 3, "c": 4}
  39. def test_nested_merge(self):
  40. base = {"model": {"name": "qwen", "temperature": 0.3}}
  41. over = {"model": {"name": "gpt-4"}}
  42. result = _deep_merge(base, over)
  43. assert result["model"]["name"] == "gpt-4"
  44. assert result["model"]["temperature"] == 0.3 # preserved
  45. def test_deep_nested(self):
  46. base = {"a": {"b": {"c": 1, "d": 2}}}
  47. over = {"a": {"b": {"c": 99}}}
  48. result = _deep_merge(base, over)
  49. assert result["a"]["b"]["c"] == 99
  50. assert result["a"]["b"]["d"] == 2
  51. def test_no_mutation(self):
  52. base = {"x": {"y": 1}}
  53. over = {"x": {"y": 2}}
  54. result = _deep_merge(base, over)
  55. assert base["x"]["y"] == 1 # base unchanged
  56. # ============================================================
  57. # 2. create_instance
  58. # ============================================================
  59. class TestCreateInstance:
  60. def test_creates_directories(self, tmpdir):
  61. inst_dir = os.path.join(tmpdir, "maritime")
  62. create_instance(inst_dir, "qaagent67wiki", name="Maritime Wiki")
  63. assert os.path.isdir(os.path.join(inst_dir, "wiki"))
  64. assert os.path.isdir(os.path.join(inst_dir, "knowledge", "raw"))
  65. assert os.path.isdir(os.path.join(inst_dir, "knowledge", "processed"))
  66. assert os.path.isdir(os.path.join(inst_dir, "workspace"))
  67. def test_creates_instance_yml(self, tmpdir):
  68. inst_dir = os.path.join(tmpdir, "maritime")
  69. path = create_instance(inst_dir, "qaagent67wiki", name="Maritime Wiki")
  70. assert os.path.isfile(path)
  71. with open(path) as f:
  72. cfg = yaml.safe_load(f)
  73. assert cfg["agent"] == "qaagent67wiki"
  74. assert cfg["name"] == "Maritime Wiki"
  75. assert "knowledge" in cfg
  76. assert "wiki" in cfg
  77. def test_auto_paths(self, tmpdir):
  78. inst_dir = os.path.join(tmpdir, "finance")
  79. create_instance(inst_dir, "qaagent67wiki")
  80. with open(os.path.join(inst_dir, "instance.yml")) as f:
  81. cfg = yaml.safe_load(f)
  82. # Paths should point to instance_dir
  83. assert inst_dir in cfg["knowledge"]["baseDir"]
  84. assert inst_dir in cfg["wiki"]["dir"]
  85. # ============================================================
  86. # 3. load_instance
  87. # ============================================================
  88. class TestLoadInstance:
  89. def test_merge_template_and_instance(self, tmpdir):
  90. # Create agent template
  91. agent_dir = os.path.join(tmpdir, "agentexample", "myagent")
  92. _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
  93. "type": "react",
  94. "systemPrompt": "You are helpful",
  95. "model": {"name": "qwen", "temperature": 0.3},
  96. "knowledge": {"baseDir": "./knowledge"},
  97. })
  98. # Create instance
  99. inst_dir = os.path.join(tmpdir, "instances", "domain1")
  100. os.makedirs(inst_dir, exist_ok=True)
  101. _write_yaml(os.path.join(inst_dir, "instance.yml"), {
  102. "agent": agent_dir,
  103. "name": "Domain 1",
  104. "knowledge": {"baseDir": "/data/domain1/knowledge"},
  105. "model": {"name": "gpt-4"},
  106. })
  107. config = load_instance(os.path.join(inst_dir, "instance.yml"))
  108. # Template fields preserved
  109. assert config["type"] == "react"
  110. assert config["systemPrompt"] == "You are helpful"
  111. # Instance overrides applied
  112. assert config["knowledge"]["baseDir"] == "/data/domain1/knowledge"
  113. assert config["model"]["name"] == "gpt-4"
  114. # Nested merge: temperature preserved from template
  115. assert config["model"]["temperature"] == 0.3
  116. # Metadata injected
  117. assert config["_agent_dir"] == agent_dir
  118. assert config["_instance_dir"] == inst_dir
  119. assert config["_instance_name"] == "Domain 1"
  120. # ============================================================
  121. # 4. load_instance_from_dirs
  122. # ============================================================
  123. class TestLoadInstanceFromDirs:
  124. def test_with_instance_yml(self, tmpdir):
  125. agent_dir = os.path.join(tmpdir, "agent")
  126. inst_dir = os.path.join(tmpdir, "instance")
  127. _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
  128. "type": "simple",
  129. "systemPrompt": "base",
  130. "knowledge": {"baseDir": "./kb"},
  131. })
  132. os.makedirs(inst_dir, exist_ok=True)
  133. _write_yaml(os.path.join(inst_dir, "instance.yml"), {
  134. "agent": agent_dir,
  135. "knowledge": {"baseDir": "/data/prod/kb"},
  136. })
  137. config = load_instance_from_dirs(agent_dir, inst_dir)
  138. assert config["knowledge"]["baseDir"] == "/data/prod/kb"
  139. assert config["systemPrompt"] == "base"
  140. def test_without_instance_yml(self, tmpdir):
  141. agent_dir = os.path.join(tmpdir, "agent")
  142. inst_dir = os.path.join(tmpdir, "instance")
  143. _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
  144. "type": "simple",
  145. "knowledge": {"baseDir": "./default"},
  146. })
  147. os.makedirs(inst_dir, exist_ok=True)
  148. config = load_instance_from_dirs(agent_dir, inst_dir)
  149. assert config["knowledge"]["baseDir"] == "./default"
  150. assert config["_instance_dir"] == os.path.abspath(inst_dir)
  151. # ============================================================
  152. # 5. Multiple Instances from Same Template
  153. # ============================================================
  154. class TestMultipleInstances:
  155. def test_two_instances_share_template(self, tmpdir):
  156. agent_dir = os.path.join(tmpdir, "template")
  157. _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
  158. "type": "react",
  159. "systemPrompt": "Wiki agent",
  160. "knowledge": {"baseDir": "./default"},
  161. })
  162. # Instance A: maritime
  163. inst_a = os.path.join(tmpdir, "maritime")
  164. os.makedirs(inst_a, exist_ok=True)
  165. _write_yaml(os.path.join(inst_a, "instance.yml"), {
  166. "agent": agent_dir,
  167. "name": "Maritime",
  168. "knowledge": {"baseDir": "/data/maritime"},
  169. })
  170. # Instance B: medical
  171. inst_b = os.path.join(tmpdir, "medical")
  172. os.makedirs(inst_b, exist_ok=True)
  173. _write_yaml(os.path.join(inst_b, "instance.yml"), {
  174. "agent": agent_dir,
  175. "name": "Medical",
  176. "knowledge": {"baseDir": "/data/medical"},
  177. })
  178. cfg_a = load_instance(os.path.join(inst_a, "instance.yml"))
  179. cfg_b = load_instance(os.path.join(inst_b, "instance.yml"))
  180. # Same template
  181. assert cfg_a["type"] == cfg_b["type"] == "react"
  182. assert cfg_a["systemPrompt"] == cfg_b["systemPrompt"]
  183. # Different data dirs
  184. assert cfg_a["knowledge"]["baseDir"] == "/data/maritime"
  185. assert cfg_b["knowledge"]["baseDir"] == "/data/medical"
  186. assert cfg_a["_instance_name"] == "Maritime"
  187. assert cfg_b["_instance_name"] == "Medical"
  188. # ============================================================
  189. # 6. DB persistence (audit #36)
  190. # ============================================================
  191. #
  192. # Up to this point the test suite covered only the helper layer
  193. # (_deep_merge, create_instance, load_instance). The new
  194. # `agents.agent_template` + `agents.instance_dir` DB columns — the
  195. # load-bearing piece of the documented multi-tenant template-vs-instance
  196. # model — were entirely unexercised. A typo in the INSERT or in the
  197. # round-tripping logic could ship to production with green CI.
  198. #
  199. # These tests use a fresh `:memory:` SQLite (same trick as
  200. # test_api_endpoints.py + test_authenticate.py) and verify:
  201. # - the two new columns round-trip correctly through INSERT + SELECT
  202. # - two agents sharing one template but pointing at distinct
  203. # instance_dirs do NOT collide on workspace paths
  204. class TestInstanceDBPersistence:
  205. @pytest.fixture
  206. def db(self):
  207. import os
  208. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  209. from agentpaas.db.models import Database
  210. import agentpaas.db.session as _session_mod
  211. prev = _session_mod._db
  212. _session_mod._db = Database("sqlite:///:memory:")
  213. try:
  214. yield _session_mod._db
  215. finally:
  216. _session_mod._db = prev
  217. def _insert_agent(self, db, agent_id: str, tenant_id: str, name: str,
  218. agent_template: str, instance_dir: str) -> None:
  219. from agentpaas.db.models import now_utc
  220. now = now_utc()
  221. db.execute(
  222. "INSERT INTO agents "
  223. "(id, tenant_id, name, description, agent_template, instance_dir, "
  224. " status, created_at, updated_at) "
  225. "VALUES (?, ?, ?, '', ?, ?, 'active', ?, ?)",
  226. (agent_id, tenant_id, name, agent_template, instance_dir, now, now),
  227. )
  228. db.commit()
  229. def test_agent_template_and_instance_dir_round_trip(self, db, tmpdir):
  230. """INSERT → SELECT must preserve both columns. Audit #36 noted
  231. these two columns are the actual storage for the
  232. template-vs-instance model; a silent schema mismatch (e.g. column
  233. renamed but old migration left in place) would corrupt every
  234. tenant's agent records."""
  235. from agentpaas.db.models import gen_id
  236. tid = "tn_test"
  237. # Seed tenant so the FK is satisfied (even though SQLite doesn't
  238. # enforce it by default, leaving it unset is sloppy and confuses
  239. # later schema introspection).
  240. db.execute(
  241. "INSERT INTO tenants (id, name, plan, status, created_at) "
  242. "VALUES (?, 'test', 'free', 'active', '2026-01-01')",
  243. (tid,),
  244. )
  245. agent_dir = os.path.join(tmpdir, "template-agent")
  246. inst_dir = os.path.join(tmpdir, "inst-A")
  247. os.makedirs(agent_dir, exist_ok=True)
  248. os.makedirs(inst_dir, exist_ok=True)
  249. aid = gen_id("ag_")
  250. self._insert_agent(db, aid, tid, "wiki-A",
  251. agent_template=agent_dir,
  252. instance_dir=inst_dir)
  253. row = db.fetchone(
  254. "SELECT agent_template, instance_dir FROM agents WHERE id = ?",
  255. (aid,),
  256. )
  257. assert row is not None
  258. assert row["agent_template"] == agent_dir
  259. assert row["instance_dir"] == inst_dir
  260. def test_two_instances_one_template_workspace_isolated(self, db, tmpdir):
  261. """The documented multi-tenant pattern: one template (agent_dir),
  262. N instances each with its own `instance_dir`. After dispatch,
  263. the workspace path must point at the instance_dir, NEVER at the
  264. shared template — otherwise two instances stomp each other.
  265. We don't run a full PaaS dispatch here (would need the whole
  266. Runtime stack); we verify the contract at the layer the audit
  267. flagged — that the DB layer correctly disambiguates the two
  268. agents AND that load_instance reproduces distinct workspace
  269. roots for them."""
  270. from agentpaas.db.models import gen_id
  271. tid = "tn_test"
  272. db.execute(
  273. "INSERT INTO tenants (id, name, plan, status, created_at) "
  274. "VALUES (?, 'test', 'free', 'active', '2026-01-01')",
  275. (tid,),
  276. )
  277. # Shared template: one agent-config.yml that both instances inherit
  278. # from. (Loader looks for `agent-config.yml`, not `agent.yml`; see
  279. # agentpaas/engine/instance.py:82.)
  280. template_dir = os.path.join(tmpdir, "template")
  281. os.makedirs(template_dir, exist_ok=True)
  282. _write_yaml(os.path.join(template_dir, "agent-config.yml"), {
  283. "type": "react",
  284. "systemPrompt": "Shared template",
  285. "knowledge": {"baseDir": "./default"},
  286. })
  287. # Two instance dirs, each with its own instance.yml override.
  288. inst_a_dir = os.path.join(tmpdir, "inst-A")
  289. inst_b_dir = os.path.join(tmpdir, "inst-B")
  290. for d, label in ((inst_a_dir, "A"), (inst_b_dir, "B")):
  291. os.makedirs(d, exist_ok=True)
  292. _write_yaml(os.path.join(d, "instance.yml"), {
  293. "agent": template_dir,
  294. "name": f"Inst{label}",
  295. "knowledge": {"baseDir": f"/data/{label.lower()}"},
  296. })
  297. # Two agent rows: same template, different instance_dirs.
  298. aid_a = gen_id("ag_")
  299. aid_b = gen_id("ag_")
  300. self._insert_agent(db, aid_a, tid, "wiki-A",
  301. agent_template=template_dir, instance_dir=inst_a_dir)
  302. self._insert_agent(db, aid_b, tid, "wiki-B",
  303. agent_template=template_dir, instance_dir=inst_b_dir)
  304. # DB layer: distinct instance_dirs preserved.
  305. a = db.fetchone("SELECT instance_dir FROM agents WHERE id = ?", (aid_a,))
  306. b = db.fetchone("SELECT instance_dir FROM agents WHERE id = ?", (aid_b,))
  307. assert a["instance_dir"] != b["instance_dir"]
  308. # Config layer: load_instance for each yields distinct
  309. # _instance_dir, so any downstream workspace_path computation
  310. # (whether {instance_dir}/workspace/run_X or otherwise) will
  311. # also be distinct.
  312. cfg_a = load_instance(os.path.join(inst_a_dir, "instance.yml"))
  313. cfg_b = load_instance(os.path.join(inst_b_dir, "instance.yml"))
  314. assert cfg_a["_instance_dir"] != cfg_b["_instance_dir"]
  315. assert cfg_a["knowledge"]["baseDir"] != cfg_b["knowledge"]["baseDir"]
  316. # Both still inherit the template's prompt.
  317. assert cfg_a["systemPrompt"] == cfg_b["systemPrompt"] == "Shared template"
  318. def test_agent_template_can_be_null_for_legacy_agents(self, db, tmpdir):
  319. """Legacy agents (created before the template/instance feature)
  320. will have NULL `agent_template` and NULL `instance_dir`. The
  321. DB column must allow that without erroring; SELECT must return
  322. None for those fields."""
  323. from agentpaas.db.models import gen_id
  324. tid = "tn_test"
  325. db.execute(
  326. "INSERT INTO tenants (id, name, plan, status, created_at) "
  327. "VALUES (?, 'test', 'free', 'active', '2026-01-01')",
  328. (tid,),
  329. )
  330. aid = gen_id("ag_")
  331. # Explicitly INSERT without setting agent_template / instance_dir
  332. # (use a minimal column list to skip them).
  333. db.execute(
  334. "INSERT INTO agents "
  335. "(id, tenant_id, name, status, created_at, updated_at) "
  336. "VALUES (?, ?, 'legacy-agent', 'active', "
  337. " '2026-01-01', '2026-01-01')",
  338. (aid, tid),
  339. )
  340. db.commit()
  341. row = db.fetchone(
  342. "SELECT agent_template, instance_dir FROM agents WHERE id = ?",
  343. (aid,),
  344. )
  345. assert row is not None
  346. assert row["agent_template"] in (None, "")
  347. assert row["instance_dir"] in (None, "")