| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- """
- Tests for Agent Instance mechanism.
- Tests cover:
- 1. _deep_merge() — dict merging semantics
- 2. create_instance() — directory and file creation
- 3. load_instance() — merge agent template + instance overrides
- 4. load_instance_from_dirs() — two-dir loading
- 5. Multiple instances from same template
- """
- import json
- import os
- import shutil
- import tempfile
- import pytest
- import yaml
- from agentpaas.engine.instance import (
- _deep_merge,
- create_instance,
- load_instance,
- load_instance_from_dirs,
- )
- @pytest.fixture
- def tmpdir():
- d = tempfile.mkdtemp(prefix="test_instance_")
- yield d
- shutil.rmtree(d, ignore_errors=True)
- def _write_yaml(path, data):
- os.makedirs(os.path.dirname(path), exist_ok=True)
- with open(path, "w") as f:
- yaml.dump(data, f, allow_unicode=True)
- # ============================================================
- # 1. _deep_merge
- # ============================================================
- class TestDeepMerge:
- def test_simple_override(self):
- base = {"a": 1, "b": 2}
- over = {"b": 3, "c": 4}
- assert _deep_merge(base, over) == {"a": 1, "b": 3, "c": 4}
- def test_nested_merge(self):
- base = {"model": {"name": "qwen", "temperature": 0.3}}
- over = {"model": {"name": "gpt-4"}}
- result = _deep_merge(base, over)
- assert result["model"]["name"] == "gpt-4"
- assert result["model"]["temperature"] == 0.3 # preserved
- def test_deep_nested(self):
- base = {"a": {"b": {"c": 1, "d": 2}}}
- over = {"a": {"b": {"c": 99}}}
- result = _deep_merge(base, over)
- assert result["a"]["b"]["c"] == 99
- assert result["a"]["b"]["d"] == 2
- def test_no_mutation(self):
- base = {"x": {"y": 1}}
- over = {"x": {"y": 2}}
- result = _deep_merge(base, over)
- assert base["x"]["y"] == 1 # base unchanged
- # ============================================================
- # 2. create_instance
- # ============================================================
- class TestCreateInstance:
- def test_creates_directories(self, tmpdir):
- inst_dir = os.path.join(tmpdir, "maritime")
- create_instance(inst_dir, "qaagent67wiki", name="Maritime Wiki")
- assert os.path.isdir(os.path.join(inst_dir, "wiki"))
- assert os.path.isdir(os.path.join(inst_dir, "knowledge", "raw"))
- assert os.path.isdir(os.path.join(inst_dir, "knowledge", "processed"))
- assert os.path.isdir(os.path.join(inst_dir, "workspace"))
- def test_creates_instance_yml(self, tmpdir):
- inst_dir = os.path.join(tmpdir, "maritime")
- path = create_instance(inst_dir, "qaagent67wiki", name="Maritime Wiki")
- assert os.path.isfile(path)
- with open(path) as f:
- cfg = yaml.safe_load(f)
- assert cfg["agent"] == "qaagent67wiki"
- assert cfg["name"] == "Maritime Wiki"
- assert "knowledge" in cfg
- assert "wiki" in cfg
- def test_auto_paths(self, tmpdir):
- inst_dir = os.path.join(tmpdir, "finance")
- create_instance(inst_dir, "qaagent67wiki")
- with open(os.path.join(inst_dir, "instance.yml")) as f:
- cfg = yaml.safe_load(f)
- # Paths should point to instance_dir
- assert inst_dir in cfg["knowledge"]["baseDir"]
- assert inst_dir in cfg["wiki"]["dir"]
- # ============================================================
- # 3. load_instance
- # ============================================================
- class TestLoadInstance:
- def test_merge_template_and_instance(self, tmpdir):
- # Create agent template
- agent_dir = os.path.join(tmpdir, "agentexample", "myagent")
- _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
- "type": "react",
- "systemPrompt": "You are helpful",
- "model": {"name": "qwen", "temperature": 0.3},
- "knowledge": {"baseDir": "./knowledge"},
- })
- # Create instance
- inst_dir = os.path.join(tmpdir, "instances", "domain1")
- os.makedirs(inst_dir, exist_ok=True)
- _write_yaml(os.path.join(inst_dir, "instance.yml"), {
- "agent": agent_dir,
- "name": "Domain 1",
- "knowledge": {"baseDir": "/data/domain1/knowledge"},
- "model": {"name": "gpt-4"},
- })
- config = load_instance(os.path.join(inst_dir, "instance.yml"))
- # Template fields preserved
- assert config["type"] == "react"
- assert config["systemPrompt"] == "You are helpful"
- # Instance overrides applied
- assert config["knowledge"]["baseDir"] == "/data/domain1/knowledge"
- assert config["model"]["name"] == "gpt-4"
- # Nested merge: temperature preserved from template
- assert config["model"]["temperature"] == 0.3
- # Metadata injected
- assert config["_agent_dir"] == agent_dir
- assert config["_instance_dir"] == inst_dir
- assert config["_instance_name"] == "Domain 1"
- # ============================================================
- # 4. load_instance_from_dirs
- # ============================================================
- class TestLoadInstanceFromDirs:
- def test_with_instance_yml(self, tmpdir):
- agent_dir = os.path.join(tmpdir, "agent")
- inst_dir = os.path.join(tmpdir, "instance")
- _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
- "type": "simple",
- "systemPrompt": "base",
- "knowledge": {"baseDir": "./kb"},
- })
- os.makedirs(inst_dir, exist_ok=True)
- _write_yaml(os.path.join(inst_dir, "instance.yml"), {
- "agent": agent_dir,
- "knowledge": {"baseDir": "/data/prod/kb"},
- })
- config = load_instance_from_dirs(agent_dir, inst_dir)
- assert config["knowledge"]["baseDir"] == "/data/prod/kb"
- assert config["systemPrompt"] == "base"
- def test_without_instance_yml(self, tmpdir):
- agent_dir = os.path.join(tmpdir, "agent")
- inst_dir = os.path.join(tmpdir, "instance")
- _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
- "type": "simple",
- "knowledge": {"baseDir": "./default"},
- })
- os.makedirs(inst_dir, exist_ok=True)
- config = load_instance_from_dirs(agent_dir, inst_dir)
- assert config["knowledge"]["baseDir"] == "./default"
- assert config["_instance_dir"] == os.path.abspath(inst_dir)
- # ============================================================
- # 5. Multiple Instances from Same Template
- # ============================================================
- class TestMultipleInstances:
- def test_two_instances_share_template(self, tmpdir):
- agent_dir = os.path.join(tmpdir, "template")
- _write_yaml(os.path.join(agent_dir, "agent-config.yml"), {
- "type": "react",
- "systemPrompt": "Wiki agent",
- "knowledge": {"baseDir": "./default"},
- })
- # Instance A: maritime
- inst_a = os.path.join(tmpdir, "maritime")
- os.makedirs(inst_a, exist_ok=True)
- _write_yaml(os.path.join(inst_a, "instance.yml"), {
- "agent": agent_dir,
- "name": "Maritime",
- "knowledge": {"baseDir": "/data/maritime"},
- })
- # Instance B: medical
- inst_b = os.path.join(tmpdir, "medical")
- os.makedirs(inst_b, exist_ok=True)
- _write_yaml(os.path.join(inst_b, "instance.yml"), {
- "agent": agent_dir,
- "name": "Medical",
- "knowledge": {"baseDir": "/data/medical"},
- })
- cfg_a = load_instance(os.path.join(inst_a, "instance.yml"))
- cfg_b = load_instance(os.path.join(inst_b, "instance.yml"))
- # Same template
- assert cfg_a["type"] == cfg_b["type"] == "react"
- assert cfg_a["systemPrompt"] == cfg_b["systemPrompt"]
- # Different data dirs
- assert cfg_a["knowledge"]["baseDir"] == "/data/maritime"
- assert cfg_b["knowledge"]["baseDir"] == "/data/medical"
- assert cfg_a["_instance_name"] == "Maritime"
- assert cfg_b["_instance_name"] == "Medical"
- # ============================================================
- # 6. DB persistence (audit #36)
- # ============================================================
- #
- # Up to this point the test suite covered only the helper layer
- # (_deep_merge, create_instance, load_instance). The new
- # `agents.agent_template` + `agents.instance_dir` DB columns — the
- # load-bearing piece of the documented multi-tenant template-vs-instance
- # model — were entirely unexercised. A typo in the INSERT or in the
- # round-tripping logic could ship to production with green CI.
- #
- # These tests use a fresh `:memory:` SQLite (same trick as
- # test_api_endpoints.py + test_authenticate.py) and verify:
- # - the two new columns round-trip correctly through INSERT + SELECT
- # - two agents sharing one template but pointing at distinct
- # instance_dirs do NOT collide on workspace paths
- class TestInstanceDBPersistence:
- @pytest.fixture
- def db(self):
- import os
- os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
- from agentpaas.db.models import Database
- import agentpaas.db.session as _session_mod
- prev = _session_mod._db
- _session_mod._db = Database("sqlite:///:memory:")
- try:
- yield _session_mod._db
- finally:
- _session_mod._db = prev
- def _insert_agent(self, db, agent_id: str, tenant_id: str, name: str,
- agent_template: str, instance_dir: str) -> None:
- from agentpaas.db.models import now_utc
- now = now_utc()
- db.execute(
- "INSERT INTO agents "
- "(id, tenant_id, name, description, agent_template, instance_dir, "
- " status, created_at, updated_at) "
- "VALUES (?, ?, ?, '', ?, ?, 'active', ?, ?)",
- (agent_id, tenant_id, name, agent_template, instance_dir, now, now),
- )
- db.commit()
- def test_agent_template_and_instance_dir_round_trip(self, db, tmpdir):
- """INSERT → SELECT must preserve both columns. Audit #36 noted
- these two columns are the actual storage for the
- template-vs-instance model; a silent schema mismatch (e.g. column
- renamed but old migration left in place) would corrupt every
- tenant's agent records."""
- from agentpaas.db.models import gen_id
- tid = "tn_test"
- # Seed tenant so the FK is satisfied (even though SQLite doesn't
- # enforce it by default, leaving it unset is sloppy and confuses
- # later schema introspection).
- db.execute(
- "INSERT INTO tenants (id, name, plan, status, created_at) "
- "VALUES (?, 'test', 'free', 'active', '2026-01-01')",
- (tid,),
- )
- agent_dir = os.path.join(tmpdir, "template-agent")
- inst_dir = os.path.join(tmpdir, "inst-A")
- os.makedirs(agent_dir, exist_ok=True)
- os.makedirs(inst_dir, exist_ok=True)
- aid = gen_id("ag_")
- self._insert_agent(db, aid, tid, "wiki-A",
- agent_template=agent_dir,
- instance_dir=inst_dir)
- row = db.fetchone(
- "SELECT agent_template, instance_dir FROM agents WHERE id = ?",
- (aid,),
- )
- assert row is not None
- assert row["agent_template"] == agent_dir
- assert row["instance_dir"] == inst_dir
- def test_two_instances_one_template_workspace_isolated(self, db, tmpdir):
- """The documented multi-tenant pattern: one template (agent_dir),
- N instances each with its own `instance_dir`. After dispatch,
- the workspace path must point at the instance_dir, NEVER at the
- shared template — otherwise two instances stomp each other.
- We don't run a full PaaS dispatch here (would need the whole
- Runtime stack); we verify the contract at the layer the audit
- flagged — that the DB layer correctly disambiguates the two
- agents AND that load_instance reproduces distinct workspace
- roots for them."""
- from agentpaas.db.models import gen_id
- tid = "tn_test"
- db.execute(
- "INSERT INTO tenants (id, name, plan, status, created_at) "
- "VALUES (?, 'test', 'free', 'active', '2026-01-01')",
- (tid,),
- )
- # Shared template: one agent-config.yml that both instances inherit
- # from. (Loader looks for `agent-config.yml`, not `agent.yml`; see
- # agentpaas/engine/instance.py:82.)
- template_dir = os.path.join(tmpdir, "template")
- os.makedirs(template_dir, exist_ok=True)
- _write_yaml(os.path.join(template_dir, "agent-config.yml"), {
- "type": "react",
- "systemPrompt": "Shared template",
- "knowledge": {"baseDir": "./default"},
- })
- # Two instance dirs, each with its own instance.yml override.
- inst_a_dir = os.path.join(tmpdir, "inst-A")
- inst_b_dir = os.path.join(tmpdir, "inst-B")
- for d, label in ((inst_a_dir, "A"), (inst_b_dir, "B")):
- os.makedirs(d, exist_ok=True)
- _write_yaml(os.path.join(d, "instance.yml"), {
- "agent": template_dir,
- "name": f"Inst{label}",
- "knowledge": {"baseDir": f"/data/{label.lower()}"},
- })
- # Two agent rows: same template, different instance_dirs.
- aid_a = gen_id("ag_")
- aid_b = gen_id("ag_")
- self._insert_agent(db, aid_a, tid, "wiki-A",
- agent_template=template_dir, instance_dir=inst_a_dir)
- self._insert_agent(db, aid_b, tid, "wiki-B",
- agent_template=template_dir, instance_dir=inst_b_dir)
- # DB layer: distinct instance_dirs preserved.
- a = db.fetchone("SELECT instance_dir FROM agents WHERE id = ?", (aid_a,))
- b = db.fetchone("SELECT instance_dir FROM agents WHERE id = ?", (aid_b,))
- assert a["instance_dir"] != b["instance_dir"]
- # Config layer: load_instance for each yields distinct
- # _instance_dir, so any downstream workspace_path computation
- # (whether {instance_dir}/workspace/run_X or otherwise) will
- # also be distinct.
- cfg_a = load_instance(os.path.join(inst_a_dir, "instance.yml"))
- cfg_b = load_instance(os.path.join(inst_b_dir, "instance.yml"))
- assert cfg_a["_instance_dir"] != cfg_b["_instance_dir"]
- assert cfg_a["knowledge"]["baseDir"] != cfg_b["knowledge"]["baseDir"]
- # Both still inherit the template's prompt.
- assert cfg_a["systemPrompt"] == cfg_b["systemPrompt"] == "Shared template"
- def test_agent_template_can_be_null_for_legacy_agents(self, db, tmpdir):
- """Legacy agents (created before the template/instance feature)
- will have NULL `agent_template` and NULL `instance_dir`. The
- DB column must allow that without erroring; SELECT must return
- None for those fields."""
- from agentpaas.db.models import gen_id
- tid = "tn_test"
- db.execute(
- "INSERT INTO tenants (id, name, plan, status, created_at) "
- "VALUES (?, 'test', 'free', 'active', '2026-01-01')",
- (tid,),
- )
- aid = gen_id("ag_")
- # Explicitly INSERT without setting agent_template / instance_dir
- # (use a minimal column list to skip them).
- db.execute(
- "INSERT INTO agents "
- "(id, tenant_id, name, status, created_at, updated_at) "
- "VALUES (?, ?, 'legacy-agent', 'active', "
- " '2026-01-01', '2026-01-01')",
- (aid, tid),
- )
- db.commit()
- row = db.fetchone(
- "SELECT agent_template, instance_dir FROM agents WHERE id = ?",
- (aid,),
- )
- assert row is not None
- assert row["agent_template"] in (None, "")
- assert row["instance_dir"] in (None, "")
|