Kaynağa Gözat

feat: agent instance mechanism — one template, N domain instances

Agent is function definition, Instance is function call.
One agent template (qaagent67wiki) can be instantiated for maritime,
medical, finance, etc. — each with its own wiki/knowledge/workspace.

New files:
- agentpaas/engine/instance.py — core instance logic:
  _deep_merge(), load_instance(), load_instance_from_dirs(),
  create_instance(), _resolve_agent_dir()
- agentexample/instances/maritime/instance.yml — maritime domain
- agentexample/instances/medical/instance.yml — medical domain (example)
- tests/test_instance.py — 11 tests

Changes:
- agents table: +agent_template, +instance_dir columns
- agents.py: CreateAgentRequest accepts instance_dir/agent_template;
  run_agent uses instance_dir for workspace path resolution
- qaagent67wiki/agent-config.yml: hardcoded paths → relative defaults
  (actual paths come from instance.yml override)
- qaagent67/agent-config.yml: same treatment
- .gitignore: ignore instance wiki/knowledge data, track instance.yml

Architecture:
  agentexample/qaagent67wiki/     ← template (versioned)
  instances/maritime/instance.yml ← config override
  instances/maritime/wiki/        ← accumulated knowledge (not versioned)
  instances/maritime/workspace/   ← per-run data (not versioned)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kenny67nju 5 ay önce
ebeveyn
işleme
c5cdb60d07

+ 9 - 0
.gitignore

@@ -167,3 +167,12 @@ patents/patent-01-lint/node_modules/
 # ==============================
 **/workspace/
 !**/workspace/.gitkeep
+
+# ==============================
+# Agent instance data — accumulated knowledge, not tracked
+# Instance dirs contain wiki/knowledge data that grows at runtime.
+# Only instance.yml is tracked (config); data dirs are ignored.
+# ==============================
+**/instances/*/wiki/
+**/instances/*/knowledge/
+!**/instances/*/instance.yml

+ 34 - 0
agentexample/instances/maritime/instance.yml

@@ -0,0 +1,34 @@
+# ════════════════════════════════════════════════════════════
+# maritime — 海事法规 Wiki 实例
+# ════════════════════════════════════════════════════════════
+# Agent 模板: qaagent67wiki(Wiki 模式文档问答智能体)
+# 数据领域: 海事法规、政策文件、船员管理
+#
+# 用法:
+#   lambdagent run qaagent67wiki --instance instances/maritime/instance.yml
+#   POST /api/v1/agents { "agent_template": "qaagent67wiki", "instance_dir": ".../maritime" }
+# ════════════════════════════════════════════════════════════
+
+agent: qaagent67wiki
+name: 海事法规 Wiki
+description: >
+  海事领域法规文档 Wiki 智能体实例。
+  基于 qaagent67wiki 模板,数据目录独立于代码。
+
+# ═══ 路径覆盖(部署时修改此处即可) ═══
+knowledge:
+  baseDir: /data/knowledge/maritime
+  rawDir: /data/knowledge/maritime/raw
+  processedDir: /data/knowledge/maritime/processed
+  indexFile: /data/knowledge/maritime/rag_index.json
+  keywordFile: /data/knowledge/maritime/rag_index.keywords.json
+
+wiki:
+  dir: /data/instances/maritime/wiki
+  relationsFile: /data/instances/maritime/wiki/relations.json
+
+# ═══ 可选的模型覆盖(使用本地 Ollama) ═══
+model:
+  provider: ollama
+  name: qwen2.5:32b
+  baseUrl: http://localhost:11434

+ 20 - 0
agentexample/instances/medical/instance.yml

@@ -0,0 +1,20 @@
+# ════════════════════════════════════════════════════════════
+# medical — 医疗法规 Wiki 实例(示例)
+# ════════════════════════════════════════════════════════════
+# 同一个 agent 模板 (qaagent67wiki),不同领域的数据。
+# 只需修改路径和少量配置,无需复制 agent 代码。
+
+agent: qaagent67wiki
+name: 医疗法规 Wiki
+description: 医疗领域法规文档 Wiki 智能体实例。
+
+knowledge:
+  baseDir: /data/knowledge/medical
+  rawDir: /data/knowledge/medical/raw
+  processedDir: /data/knowledge/medical/processed
+  indexFile: /data/knowledge/medical/rag_index.json
+  keywordFile: /data/knowledge/medical/rag_index.keywords.json
+
+wiki:
+  dir: /data/instances/medical/wiki
+  relationsFile: /data/instances/medical/wiki/relations.json

+ 110 - 0
agentexample/qaagent67/agent-config.yml

@@ -0,0 +1,110 @@
+# ════════════════════════════════════════════════════════════
+# qaagent67 — 海事文档问答智能体 (RAG 模式) ❓
+# ════════════════════════════════════════════════════════════
+agentId: qaagent67-maritime-v1
+name: qaagent
+description: >
+  海事领域文档问答智能体 (RAG 模式)。支持加入 PDF/txt/md 文件,
+  自动分块索引到知识库。针对海事法规、政策、船员管理等内容提问,
+  回答带引用来源,并经过事实核查确保准确性。
+
+type: react
+
+model:
+  provider: ollama
+  name: qwen2.5:32b
+  temperature: 0.3
+  maxTokens: 6144
+  conversation: true
+  maxHistoryTokens: 40000
+  baseUrl: http://localhost:11434
+
+systemPrompt: |
+  你是 qaagent (❓),一个专业的海事领域文档问答助手。
+
+  ## 你的能力
+
+  1. **加入文件**: 用户提供文件路径或目录时,调用 IngestFiles 将文件索引到知识库
+  2. **简单问答**: 直接事实查询,调用 QueryKnowledge 从知识库检索并回答
+  3. **深度分析**: 复杂推理(对比、总结、评估),调用 DeepAnalysis 启动子智能体并行处理
+  4. **查看知识库**: 调用 ListKnowledge 查看索引状态
+  5. **删除文件**: 调用 RemoveKnowledge 删除索引
+
+  ## 关键决策: 简单问答 vs 深度分析
+
+  **用 QueryKnowledge (快, 单次检索):**
+  - 船员适任证书的申请条件? → 事实查找
+  - 琼州海峡定线制何时施行? → 精确查找
+  - 船舶进出港报告制度的要求? → 直接检索
+
+  **用 DeepAnalysis (深, 多路并行推理):**
+  - 对比不同年份船员发展报告的变化 → 需要分别检索 + 对比
+  - 总结海事局近年来的主要政策方向 → 需要跨文件分析
+  - 从安全、环保、效率三个角度评估船舶管理制度 → 需要多角度并行
+
+  ## 工作原则
+  - **只基于文档内容回答**,不要编造信息
+  - 如果文档中没有相关信息,坦诚告知用户
+  - 回答要具体,引用文档中的原文作为依据
+  - 每个回答都带来源标注: [来源: 文件名 位置]
+  - 对于数字、日期等事实性信息,必须与文档原文一致
+
+react:
+  maxSteps: 20
+  observationEnabled: true
+  toolTimeout: 120
+  thinkTimeout: 300
+  verbose: true
+
+memory:
+  enabled: true
+  strategy: local
+  size: 50
+  ttl: 14400
+
+mcp:
+  localTools:
+    - IngestFiles
+    - QueryKnowledge
+    - ListKnowledge
+    - RemoveKnowledge
+    - DeepAnalysis
+    - ReadFile
+    - ListFiles
+    - SearchContent
+    - Bash
+    - terminate
+  policy:
+    mode: auto
+
+guard:
+  dangerousCommandBlock: true
+  highRiskConfirmation: false
+  maxOutputLength: 50000
+  retry: 0
+  fallback: last
+
+persona:
+  name: qaagent
+  style: helpful-precise
+  template: qaagent
+
+runtime:
+  engine: cek
+  costBudget: 0.50
+  maxSteps: 10000
+
+# ═══ 知识库配置(模板默认值,实际部署由 instance.yml 覆盖)═══
+knowledge:
+  baseDir: ./knowledge
+  rawDir: ./knowledge/raw
+  processedDir: ./knowledge/processed
+  indexFile: ./knowledge/rag_index.json
+  keywordFile: ./knowledge/rag_index.keywords.json
+
+rag:
+  enabled: true
+  collection: maritime_rag
+  chunkSize: 512
+  chunkOverlap: 64
+  topK: 5

+ 117 - 0
agentexample/qaagent67wiki/agent-config.yml

@@ -0,0 +1,117 @@
+# ════════════════════════════════════════════════════════════
+# qaagent67wiki — 海事 LLM Wiki 文档问答智能体 📚
+# ════════════════════════════════════════════════════════════
+agentId: qaagent67wiki-maritime-v1
+name: wikiagent
+description: >
+  海事领域 LLM Wiki 文档问答智能体。基于 Karpathy LLM Wiki 模式,
+  LLM 将海事法规、政策文件编译为持续积累的 wiki 知识库。
+  喂文件时理解并写入 wiki,提问时查阅已编译的知识。
+
+type: react
+
+model:
+  provider: ollama
+  name: qwen2.5:32b
+  temperature: 0.3
+  maxTokens: 8192
+  conversation: true
+  maxHistoryTokens: 40000
+  baseUrl: http://localhost:11434
+
+systemPrompt: |
+  你是 wikiagent (📚),一个基于 LLM Wiki 模式的海事领域文档问答助手。
+
+  ## 核心理念
+  你不是 RAG 检索器,你是 wiki 维护者。当用户加入文件时,你不是简单分块存储,
+  而是阅读理解后将知识编译到一个持续积累的 wiki 中。wiki 越用越丰富。
+
+  ## 三大操作
+
+  **WikiIngest** — 喂文件 (知识编译)
+  用户提供文件/目录 → 你阅读理解 → 写入 wiki 页面:
+    - sources/ 下创建源文件摘要页
+    - entities/ 下创建或更新实体页 (法规名称/机构/船舶类型/概念)
+    - topics/ 下创建或更新主题页 (跨文件综合)
+    - 更新 index.md 和 log.md
+  一个源文件可能触及 10-15 个 wiki 页面。这是知识编译。
+
+  **WikiQuery** — 提问 (查阅已编译知识)
+  用户提问 → 你读 index.md 找相关页面 → 读 wiki 页面 → 回答
+  不需要从碎片重搜——wiki 页面已经是综合好的知识。
+
+  **WikiLint** — 健康检查
+  检查 wiki 质量: 矛盾、孤页、缺页、过时内容。
+
+  ## 海事领域特化
+  - 关注法规层级: 全国人大 > 国务院 > 交通运输部 > 海事局
+  - 注意法规的时效性: 修订日期、施行日期、废止日期
+  - 实体类型: 法规、机构、船舶类型、证书、水域、港口
+  - 主题类型: 船员管理、船舶安全、海洋环保、港口管理、航道管理
+
+  ## 工作原则
+  - 只基于源文件内容写 wiki,不编造
+  - wiki 页面用 [[双括号]] 做内链
+  - 每个事实标注来源: [来源: 文件名]
+  - 发现矛盾时用 ⚠️ 标注,不要静默覆盖
+  - index.md 是你的导航地图,每次 Ingest 后必须更新
+
+react:
+  maxSteps: 25
+  observationEnabled: true
+  toolTimeout: 120
+  thinkTimeout: 300
+  verbose: true
+
+memory:
+  enabled: true
+  strategy: local
+  size: 50
+  ttl: 14400
+
+mcp:
+  localTools:
+    - WikiIngest
+    - WikiQuery
+    - WikiLint
+    - WikiSearch
+    - WikiStatus
+    - ReadFile
+    - WriteFile
+    - ListFiles
+    - SearchContent
+    - Bash
+    - terminate
+  policy:
+    mode: auto
+
+guard:
+  dangerousCommandBlock: true
+  highRiskConfirmation: false
+  maxOutputLength: 50000
+  retry: 0
+  fallback: last
+
+persona:
+  name: wikiagent
+  style: meticulous-scholarly
+  template: wikiagent
+
+runtime:
+  engine: cek
+  costBudget: 1.00
+  maxSteps: 10000
+
+# ═══ 知识库配置(模板默认值,实际部署由 instance.yml 覆盖)═══
+# 使用 instance 机制时,这些路径会被 instance.yml 中的值深度合并覆盖。
+# 直接运行此模板时,使用以下默认值。
+knowledge:
+  baseDir: ./knowledge
+  rawDir: ./knowledge/raw
+  processedDir: ./knowledge/processed
+  indexFile: ./knowledge/rag_index.json
+  keywordFile: ./knowledge/rag_index.keywords.json
+
+wiki:
+  dir: ./wiki
+  relationsFile: ./wiki/relations.json

+ 27 - 5
agentpaas/api/v1/agents.py

@@ -37,6 +37,9 @@ class CreateAgentRequest(BaseModel):
     config: Dict[str, Any]
     tags: list = Field(default_factory=list)
     environment: str = Field(default="production", max_length=64)
+    agent_dir: str = Field(default="", description="Agent template directory path")
+    agent_template: str = Field(default="", description="Agent template name (e.g. qaagent67wiki)")
+    instance_dir: str = Field(default="", description="Instance data directory for this agent")
 
 class UpdateAgentRequest(BaseModel):
     config: Dict[str, Any]
@@ -87,12 +90,30 @@ async def create_agent(
     except Exception as e:
         api_error(400, "INVALID_CONFIG", str(e))
 
+    # Resolve instance: if instance_dir is set, load and merge config
+    agent_dir = req.agent_dir
+    instance_dir = req.instance_dir
+    if instance_dir:
+        from agentpaas.engine.instance import load_instance_from_dirs
+        if agent_dir:
+            merged = load_instance_from_dirs(agent_dir, instance_dir)
+            # Use instance-merged config for validation
+            config_json = json.dumps(merged, ensure_ascii=False, default=str)
+            config_hash = hashlib.sha256(config_json.encode()).hexdigest()
+            # Re-validate
+            try:
+                _compile_agent(merged)
+            except Exception as e:
+                api_error(400, "INVALID_CONFIG", f"Instance config error: {e}")
+
     now = now_utc()
     db.execute(
-        "INSERT INTO agents (id, tenant_id, name, description, current_version, tags, environment, status, created_at, updated_at) "
-        "VALUES (?, ?, ?, ?, 1, ?, ?, 'active', ?, ?)",
+        "INSERT INTO agents (id, tenant_id, name, description, current_version, tags, environment, "
+        "agent_dir, agent_template, instance_dir, status, created_at, updated_at) "
+        "VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, 'active', ?, ?)",
         (agent_id, tenant.tenant_id, req.name, req.description,
-         json.dumps(req.tags), req.environment, now, now)
+         json.dumps(req.tags), req.environment,
+         agent_dir, req.agent_template, instance_dir, now, now)
     )
     db.execute(
         "INSERT INTO agent_versions (agent_id, version, config, config_hash, changelog, created_by, created_at) "
@@ -311,8 +332,9 @@ async def run_agent(
     )
     db.commit()
 
-    # Resolve agent_dir (from DB or empty)
-    agent_dir = agent.get("agent_dir", "") or ""
+    # Resolve agent_dir: prefer instance_dir (per-instance data), fallback to agent_dir
+    instance_dir = agent.get("instance_dir", "") or ""
+    agent_dir = instance_dir or agent.get("agent_dir", "") or ""
 
     # Execute via lambdagent
     t0 = time.time()

+ 4 - 0
agentpaas/db/models.py

@@ -85,6 +85,8 @@ class Database:
                 environment TEXT DEFAULT 'production',
                 traffic_rules TEXT,
                 agent_dir TEXT,
+                agent_template TEXT,
+                instance_dir TEXT,
                 status TEXT DEFAULT 'active',
                 created_at TEXT,
                 updated_at TEXT
@@ -175,6 +177,8 @@ class Database:
         """Add columns to existing tables if missing (safe for fresh DBs too)."""
         migrations = [
             ("agents", "agent_dir", "ALTER TABLE agents ADD COLUMN agent_dir TEXT"),
+            ("agents", "agent_template", "ALTER TABLE agents ADD COLUMN agent_template TEXT"),
+            ("agents", "instance_dir", "ALTER TABLE agents ADD COLUMN instance_dir TEXT"),
             ("runs", "workspace_path", "ALTER TABLE runs ADD COLUMN workspace_path TEXT"),
         ]
         for table, column, sql in migrations:

+ 245 - 0
agentpaas/engine/instance.py

@@ -0,0 +1,245 @@
+"""
+engine.instance — Agent Instance 管理
+
+Agent 是函数定义,Instance 是函数调用。
+一份 agent 代码(模板)可以被不同领域的数据实例化多次。
+
+目录结构:
+    agentexample/qaagent67wiki/          ← agent 模板(版本控制)
+        agent-config.yml
+        agents/, skills/, scripts/
+
+    /data/instances/maritime/            ← 实例数据(不版本控制)
+        instance.yml                     ← 覆盖配置(指向 agent 模板 + 路径覆盖)
+        wiki/                            ← 累积知识
+        knowledge/                       ← 原始文件 + 索引
+        workspace/                       ← per-run 数据
+
+用法:
+    config = load_instance("/data/instances/maritime/instance.yml")
+    # config = agent-config.yml 的内容,被 instance.yml 覆盖后的合并结果
+
+    # 或从 agent_dir + instance_dir 分别指定:
+    config = load_instance_from_dirs(
+        agent_dir="agentexample/qaagent67wiki",
+        instance_dir="/data/instances/maritime",
+    )
+"""
+from __future__ import annotations
+
+import copy
+import os
+import time
+from typing import Any, Dict, Optional
+
+import yaml
+
+
+def _deep_merge(base: dict, override: dict) -> dict:
+    """
+    深度合并两个 dict。override 的值覆盖 base 的值。
+    嵌套 dict 递归合并,非 dict 值直接覆盖。
+    """
+    result = copy.deepcopy(base)
+    for key, val in override.items():
+        if key in result and isinstance(result[key], dict) and isinstance(val, dict):
+            result[key] = _deep_merge(result[key], val)
+        else:
+            result[key] = copy.deepcopy(val)
+    return result
+
+
+def load_instance(instance_path: str) -> Dict[str, Any]:
+    """
+    从 instance.yml 加载合并后的配置。
+
+    instance.yml 必须包含 `agent` 字段指向 agent 模板目录。
+    其余字段深度合并覆盖 agent-config.yml。
+
+    Args:
+        instance_path: instance.yml 的路径
+
+    Returns:
+        合并后的完整配置 dict,额外包含:
+            _agent_dir: agent 模板目录绝对路径
+            _instance_dir: 实例数据目录绝对路径
+    """
+    instance_dir = os.path.dirname(os.path.abspath(instance_path))
+
+    with open(instance_path, "r", encoding="utf-8") as f:
+        instance_cfg = yaml.safe_load(f) or {}
+
+    # agent 字段: 指向 agent 模板
+    agent_ref = instance_cfg.pop("agent", None)
+    if not agent_ref:
+        raise ValueError(f"instance.yml must have 'agent' field: {instance_path}")
+
+    # 解析 agent 模板目录
+    agent_dir = _resolve_agent_dir(agent_ref, instance_dir)
+    agent_config_path = os.path.join(agent_dir, "agent-config.yml")
+
+    if not os.path.isfile(agent_config_path):
+        raise FileNotFoundError(
+            f"Agent template not found: {agent_config_path} "
+            f"(referenced by agent: {agent_ref})"
+        )
+
+    with open(agent_config_path, "r", encoding="utf-8") as f:
+        agent_cfg = yaml.safe_load(f) or {}
+
+    # 从 instance_cfg 中剥离元数据字段
+    instance_name = instance_cfg.pop("name", "")
+    instance_cfg.pop("description", "")
+
+    # 深度合并: agent_cfg 为 base, instance_cfg 为 override
+    merged = _deep_merge(agent_cfg, instance_cfg)
+
+    # 注入元数据
+    merged["_agent_dir"] = agent_dir
+    merged["_instance_dir"] = instance_dir
+    if instance_name:
+        merged["_instance_name"] = instance_name
+
+    return merged
+
+
+def load_instance_from_dirs(
+    agent_dir: str,
+    instance_dir: str,
+) -> Dict[str, Any]:
+    """
+    从分别指定的 agent_dir 和 instance_dir 加载合并配置。
+
+    如果 instance_dir 下有 instance.yml,用它覆盖 agent-config.yml。
+    如果没有 instance.yml,直接使用 agent-config.yml。
+
+    Args:
+        agent_dir: agent 模板目录
+        instance_dir: 实例数据目录
+
+    Returns:
+        合并后的配置 dict
+    """
+    agent_dir = os.path.abspath(agent_dir)
+    instance_dir = os.path.abspath(instance_dir)
+
+    agent_config_path = os.path.join(agent_dir, "agent-config.yml")
+    if not os.path.isfile(agent_config_path):
+        raise FileNotFoundError(f"Agent config not found: {agent_config_path}")
+
+    with open(agent_config_path, "r", encoding="utf-8") as f:
+        agent_cfg = yaml.safe_load(f) or {}
+
+    # 尝试加载 instance.yml
+    instance_config_path = os.path.join(instance_dir, "instance.yml")
+    if os.path.isfile(instance_config_path):
+        with open(instance_config_path, "r", encoding="utf-8") as f:
+            instance_cfg = yaml.safe_load(f) or {}
+        # 剥离元数据
+        instance_cfg.pop("agent", None)
+        instance_name = instance_cfg.pop("name", "")
+        instance_cfg.pop("description", "")
+        merged = _deep_merge(agent_cfg, instance_cfg)
+        if instance_name:
+            merged["_instance_name"] = instance_name
+    else:
+        merged = agent_cfg
+
+    merged["_agent_dir"] = agent_dir
+    merged["_instance_dir"] = instance_dir
+
+    return merged
+
+
+def create_instance(
+    instance_dir: str,
+    agent_ref: str,
+    name: str = "",
+    overrides: Dict[str, Any] | None = None,
+) -> str:
+    """
+    创建新的 agent 实例。
+
+    在 instance_dir 下创建:
+        instance.yml
+        wiki/
+        knowledge/raw/
+        knowledge/processed/
+        workspace/
+
+    Args:
+        instance_dir: 实例目录路径
+        agent_ref: agent 模板引用(目录名或路径)
+        name: 实例名称
+        overrides: 额外配置覆盖
+
+    Returns:
+        instance.yml 的路径
+    """
+    os.makedirs(instance_dir, exist_ok=True)
+    os.makedirs(os.path.join(instance_dir, "wiki"), exist_ok=True)
+    os.makedirs(os.path.join(instance_dir, "knowledge", "raw"), exist_ok=True)
+    os.makedirs(os.path.join(instance_dir, "knowledge", "processed"), exist_ok=True)
+    os.makedirs(os.path.join(instance_dir, "workspace"), exist_ok=True)
+
+    instance_cfg = {
+        "agent": agent_ref,
+        "name": name or os.path.basename(instance_dir),
+    }
+
+    # 自动生成路径覆盖
+    abs_dir = os.path.abspath(instance_dir)
+    instance_cfg["knowledge"] = {
+        "baseDir": os.path.join(abs_dir, "knowledge"),
+        "rawDir": os.path.join(abs_dir, "knowledge", "raw"),
+        "processedDir": os.path.join(abs_dir, "knowledge", "processed"),
+        "indexFile": os.path.join(abs_dir, "knowledge", "rag_index.json"),
+        "keywordFile": os.path.join(abs_dir, "knowledge", "rag_index.keywords.json"),
+    }
+    instance_cfg["wiki"] = {
+        "dir": os.path.join(abs_dir, "wiki"),
+        "relationsFile": os.path.join(abs_dir, "wiki", "relations.json"),
+    }
+
+    if overrides:
+        instance_cfg = _deep_merge(instance_cfg, overrides)
+
+    instance_path = os.path.join(instance_dir, "instance.yml")
+    with open(instance_path, "w", encoding="utf-8") as f:
+        yaml.dump(instance_cfg, f, allow_unicode=True, default_flow_style=False)
+
+    return instance_path
+
+
+def _resolve_agent_dir(agent_ref: str, instance_dir: str) -> str:
+    """
+    解析 agent 引用为绝对目录路径。
+
+    支持:
+        "qaagent67wiki"                → 在 agentexample/ 下查找
+        "../agentexample/qaagent67wiki" → 相对于 instance_dir 解析
+        "/abs/path/to/agent"           → 绝对路径
+    """
+    # 绝对路径
+    if os.path.isabs(agent_ref):
+        return agent_ref
+
+    # 相对于 instance_dir
+    relative = os.path.join(instance_dir, agent_ref)
+    if os.path.isdir(relative):
+        return os.path.abspath(relative)
+
+    # 在常见位置查找
+    search_paths = [
+        os.path.join(instance_dir, "..", "..", "agentexample", agent_ref),
+        os.path.join(os.getcwd(), "agentexample", agent_ref),
+        os.path.join(os.path.dirname(__file__), "..", "..", "agentexample", agent_ref),
+    ]
+    for path in search_paths:
+        if os.path.isdir(path):
+            return os.path.abspath(path)
+
+    raise FileNotFoundError(
+        f"Cannot resolve agent template '{agent_ref}'. "
+        f"Searched: {instance_dir}, agentexample/"
+    )

+ 233 - 0
tests/test_instance.py

@@ -0,0 +1,233 @@
+"""
+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"