From 942a9e70bc53e89488f1d90865139452aea423be Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:04:14 +0800 Subject: [PATCH 01/12] feat(agent-db): add list_pending QT query tool --- .../capabilities/agent_db/schema_read.py | 905 ++++++++++++++++++ .../capabilities/agent_db/test_schema_read.py | 842 ++++++++++++++++ 2 files changed, 1747 insertions(+) create mode 100644 src/agentpool/capabilities/agent_db/schema_read.py create mode 100644 tests/capabilities/agent_db/test_schema_read.py diff --git a/src/agentpool/capabilities/agent_db/schema_read.py b/src/agentpool/capabilities/agent_db/schema_read.py new file mode 100644 index 000000000..df1e0fb11 --- /dev/null +++ b/src/agentpool/capabilities/agent_db/schema_read.py @@ -0,0 +1,905 @@ +"""Schema-aware read tools for AgentDBCapability. + +Phase 2: Knowledge-schema-aware tools that understand the catalog, BOM, +fault-symptom graph, variant merging, and knowledge applicability structures. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from pydantic_ai.messages import ToolReturn +from pydantic_ai.tools import RunContext # noqa: TC002 - needed for get_type_hints() +import yaml + +from agentpool.capabilities.agent_db.helpers import ( + merge_variant_sections, + parse_bom_table, + parse_frontmatter, +) +from agentpool.capabilities.agent_db.visibility import URIPrefixFilter + + +if TYPE_CHECKING: + from collections.abc import Callable + + from agentpool.capabilities.agent_db import AgentDBCapability + + +# Entity-rel YAML files under viking://graph/entity_rel/ +_ENTITY_REL_FILES = ( + "manifests_as", + "caused_by", + "leads_to", + "co_occurs_with", + "addresses", + "confirmed_by", + "repaired_by", +) + +# Map directory-type segments in entity paths to knowledge-type labels +_PATH_TYPE_MAP = { + "fault": "fault", + "symptom": "symptom", + "opl": "opl", + "procedure": "procedure", + "component": "component", +} + + +def _normalize_term(term: str) -> str: + """Normalize a search term for comparison. + + Lowercases, strips dashes and spaces so that + ``"SY75C"``, ``"SY-75C"``, ``"sy 75c"`` all match ``"sy75c"``. + + Args: + term: The raw search term. + + Returns: + The normalized term. + """ + return term.lower().replace("-", "").replace(" ", "") + + +def _entity_type_from_path(path: str) -> str | None: + """Extract the knowledge type from an entity path segment. + + Looks for one of ``fault/``, ``symptom/``, ``opl/``, ``procedure/``, + ``component/`` in the path and returns the corresponding label. + + Args: + path: An entity path (e.g. ``wiki/fault/pump_failure``). + + Returns: + The type label or ``None`` if no known type segment is found. + """ + lower = path.lower() + for seg, label in _PATH_TYPE_MAP.items(): + if f"/{seg}/" in lower or lower.startswith(f"{seg}/"): + return label + return None + + +def _entity_uri_from_path(path: str) -> str: + """Build a viking:// URI from an entity path. + + If the path already starts with ``viking://`` it is returned as-is. + Otherwise ``viking://`` is prepended. + + Args: + path: The entity path. + + Returns: + A full viking:// URI. + """ + if path.startswith("viking://"): + return path + return f"viking://{path}" + + +async def _ls_recursive( + client: Any, + uri: str, + depth: int = 0, + max_depth: int = 5, +) -> list[str]: + """Recursively walk a directory tree via ``client.ls()``. + + Returns a flat list of all entry names (both files and directories) + encountered. Each entry is the last path component as returned by + the SDK. + + Args: + client: The Viking SDK client. + uri: The directory URI to list. + depth: Current recursion depth (internal). + max_depth: Maximum recursion depth. + + Returns: + A list of entry name strings. + """ + if depth > max_depth: + return [] + results: list[str] = [] + try: + entries = await client.ls(uri) + except Exception: + return results + if not entries: + return results + for entry in entries: + if isinstance(entry, str): + name = entry + is_dir = entry.endswith("/") + elif isinstance(entry, dict): + name = entry.get("name", "") + is_dir = entry.get("is_dir", False) + else: + continue + results.append(name) + if is_dir and name: + sub_uri = uri.rstrip("/") + "/" + name + results.extend(await _ls_recursive(client, sub_uri, depth + 1, max_depth)) + return results + + +async def _read_entity_rel_files( + client: Any, + base_uri: str, + names: tuple[str, ...], +) -> dict[str, list[dict[str, Any]]]: + """Read and parse multiple entity-rel YAML files. + + Args: + client: The Viking SDK client. + base_uri: Directory URI containing the YAML files (with trailing /). + names: Tuple of file names (without .yaml). + + Returns: + Dict mapping file name → list of edge dicts (empty list on failure). + """ + result: dict[str, list[dict[str, Any]]] = {} + for name in names: + file_uri = base_uri + name + ".yaml" + try: + content = await client.read(file_uri) + if not content: + result[name] = [] + continue + parsed = yaml.safe_load(content) + if isinstance(parsed, list): + result[name] = [e for e in parsed if isinstance(e, dict)] + else: + result[name] = [] + except Exception: + result[name] = [] + return result + + +def build_schema_read_tools( + cap: AgentDBCapability, +) -> list[Callable[..., Any]]: + """Build schema-aware read tool functions for AgentDBCapability. + + Returns 5 async tool closures: + - ``agentdb_resolve_identity`` — resolve serial/model to catalog node + - ``agentdb_traverse_bom`` — read and parse a device BOM + - ``agentdb_get_fault_symptom_graph`` — build fault/symptom relation graph + - ``agentdb_get_effective_knowledge`` — merge wiki entity with variant + - ``agentdb_query_applicability`` — query applicable knowledge for a device + + Args: + cap: The AgentDBCapability instance that owns these tools. + + Returns: + A list of 5 async tool functions. + """ + tools: list[Callable[..., Any]] = [] + uri_filter = URIPrefixFilter(allowed_prefixes=cap.allowed_prefixes) + + if cap.mode in ("read", "write", "all"): + # ---- 1. agentdb_resolve_identity ---- + async def agentdb_resolve_identity( + ctx: RunContext[Any], + serial_number: str = "", + marketing_model: str = "", + rd_model: str = "", + ) -> ToolReturn: + """Resolve a serial number or model name to a catalog identity node. + + Walks the ``viking://catalog/`` directory tree recursively and + matches directory names against the provided search term + (normalized: lowercase, dashes and spaces stripped). + + Args: + serial_number: Serial number (e.g. ``"SY75C-12345"``). + marketing_model: Marketing model name (e.g. ``"SY75C"``). + rd_model: R&D model code (e.g. ``"SY75C"``). + + Returns: + JSON with identity node path, catalog URI, BOM URI, and + domain URI; or an error message string if not found. + """ + search_term = serial_number or marketing_model or rd_model + if not search_term: + return ToolReturn( + return_value="Error: at least one of serial_number, marketing_model, or rd_model must be provided." + ) + catalog_uri = "viking://catalog/" + if not uri_filter.is_allowed(catalog_uri): + return ToolReturn( + return_value=f"Access denied: URI '{catalog_uri}' is not in the allowed namespaces for this agent." + ) + try: + client = await cap.viking._ensure_client() + # Search the catalog tree for a directory matching the normalized term + matched_path = await _find_catalog_path(client, catalog_uri, search_term) + if matched_path is None: + return ToolReturn( + return_value=f"Identity not found for '{search_term}' in catalog." + ) + node_path = matched_path + catalog_node_uri = f"viking://catalog/{node_path}/" + bom_uri = f"viking://catalog/{node_path}/bom.md" + # Determine domain from path segments + domain = "excavator" # default + parts = node_path.split("/") + _min_domain_parts = 2 + if len(parts) >= _min_domain_parts: + domain = parts[1] if parts[0] in ("sany", "doosan", "komatsu") else parts[0] + domain_uri = f"viking://wiki/domain/{domain}" + # Level: "model" if leaf, "series" if intermediate + _min_model_parts = 3 + level = "model" if len(parts) >= _min_model_parts else "series" + result = { + "node_path": node_path, + "level": level, + "catalog_uri": catalog_node_uri, + "bom_uri": bom_uri, + "variant_bom_uri": None, + "domain_uri": domain_uri, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_resolve_identity) + + # ---- 2. agentdb_traverse_bom ---- + async def agentdb_traverse_bom( + ctx: RunContext[Any], + identity_node: str, + system: str = "", + depth: int = 1, + ) -> ToolReturn: + """Traverse the BOM (Bill of Materials) for a specific device. + + Reads ``viking://catalog/{identity_node}/bom.md`` and parses + the BOM table. Optionally filters by system name. + + Args: + identity_node: Catalog node path (e.g. ``"sany/excavator/sy75c"``). + system: Optional system filter (e.g. ``"液压系统"``). + depth: Reserved for future hierarchical traversal. + + Returns: + JSON with device node and list of component dicts. + """ + bom_uri = f"viking://catalog/{identity_node}/bom.md" + if not uri_filter.is_allowed(bom_uri): + return ToolReturn( + return_value=f"Access denied: URI '{bom_uri}' is not in the allowed namespaces for this agent." + ) + try: + client = await cap.viking._ensure_client() + content = await client.read(bom_uri) + if not content: + return ToolReturn(return_value=f"BOM not found at {bom_uri}") + components = parse_bom_table(content) + if system: + components = [c for c in components if c.get("system", "") == system] + # Enrich each component with component_uri + enriched: list[dict[str, Any]] = [] + for comp in components: + comp_id = str(comp.get("component_id", "")) + # Build wiki component URI: replace non-alnum with _ + safe_id = comp_id.replace("/", "_").replace(":", "_") + comp_uri = f"viking://wiki/component/{safe_id}.md" + enriched.append({ + "component_id": comp_id, + "component_uri": comp_uri, + "component_name": comp.get("component_name", ""), + "material_no": comp.get("material_no", ""), + "quantity": comp.get("quantity", "1"), + "system": comp.get("system", ""), + "class_ref": comp.get("class_ref", ""), + "ecu_family": comp.get("ecu_family"), + "children": [], + }) + result = { + "device": identity_node, + "components": enriched, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_traverse_bom) + + # ---- 3. agentdb_get_fault_symptom_graph ---- + async def agentdb_get_fault_symptom_graph( + ctx: RunContext[Any], + fault_uri: str = "", + symptom_uri: str = "", + identity_node: str = "", + ) -> ToolReturn: + """Build a fault-symptom-component relationship graph. + + Reads 4 entity-rel YAML files from ``viking://graph/entity_rel/`` + (manifests_as, caused_by, leads_to, co_occurs_with) and builds + a graph of faults, symptoms, and components. + + Args: + fault_uri: Optional fault entity URI to filter by. + symptom_uri: Optional symptom entity URI to filter by. + identity_node: Optional device node (reserved for future). + + Returns: + JSON with faults, symptoms, components, edges, and + co-occurring faults. + """ + graph_base = "viking://graph/entity_rel/" + if not uri_filter.is_allowed(graph_base): + return ToolReturn( + return_value=f"Access denied: URI '{graph_base}' is not in the allowed namespaces for this agent." + ) + try: + client = await cap.viking._ensure_client() + rel_files = await _read_entity_rel_files( + client, + graph_base, + ("manifests_as", "caused_by", "leads_to", "co_occurs_with"), + ) + manifests_as = rel_files.get("manifests_as", []) + caused_by = rel_files.get("caused_by", []) + leads_to = rel_files.get("leads_to", []) + co_occurs_with = rel_files.get("co_occurs_with", []) + + # Collect all edges + all_edges: list[dict[str, Any]] = [] + for edges in (manifests_as, caused_by, leads_to, co_occurs_with): + all_edges.extend(edges) + + # Collect entity URIs by type + faults: set[str] = set() + symptoms: set[str] = set() + components: set[str] = set() + + for edge in all_edges: + for key in ("source", "target"): + val = str(edge.get(key, "")) + if not val: + continue + etype = _entity_type_from_path(val) + if etype == "fault": + faults.add(val) + elif etype == "symptom": + symptoms.add(val) + elif etype == "component": + components.add(val) + + # Filter by fault_uri or symptom_uri if provided + if fault_uri: + fault_uri_norm = fault_uri + # Keep only faults matching and edges involving it + faults = { + f + for f in faults + if f == fault_uri_norm + or _normalize_term(f) == _normalize_term(fault_uri_norm) + } + all_edges = [ + e + for e in all_edges + if _normalize_term(str(e.get("source", ""))) + == _normalize_term(fault_uri_norm) + or _normalize_term(str(e.get("target", ""))) + == _normalize_term(fault_uri_norm) + ] + # Rebuild entity sets from filtered edges + symptoms = set() + components = set() + for edge in all_edges: + for key in ("source", "target"): + val = str(edge.get(key, "")) + etype = _entity_type_from_path(val) + if etype == "symptom": + symptoms.add(val) + elif etype == "component": + components.add(val) + + if symptom_uri: + symptom_uri_norm = symptom_uri + symptoms = { + s + for s in symptoms + if s == symptom_uri_norm + or _normalize_term(s) == _normalize_term(symptom_uri_norm) + } + all_edges = [ + e + for e in all_edges + if _normalize_term(str(e.get("source", ""))) + == _normalize_term(symptom_uri_norm) + or _normalize_term(str(e.get("target", ""))) + == _normalize_term(symptom_uri_norm) + ] + faults = set() + components = set() + for edge in all_edges: + for key in ("source", "target"): + val = str(edge.get(key, "")) + etype = _entity_type_from_path(val) + if etype == "fault": + faults.add(val) + elif etype == "component": + components.add(val) + + # Co-occurring faults + co_occurring: list[str] = [] + for edge in co_occurs_with: + src = str(edge.get("source", "")) + tgt = str(edge.get("target", "")) + if faults and (src in faults or tgt in faults): + co_occurring.extend( + val + for val in (src, tgt) + if _entity_type_from_path(val) == "fault" and val not in faults + ) + + result = { + "faults": sorted(faults), + "symptoms": sorted(symptoms), + "components": sorted(components), + "edges": all_edges, + "co_occurring_faults": sorted(set(co_occurring)), + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_get_fault_symptom_graph) + + # ---- 4. agentdb_get_effective_knowledge ---- + async def agentdb_get_effective_knowledge( + ctx: RunContext[Any], + identity: str, + uri: str, + ) -> ToolReturn: + """Get the effective knowledge for a wiki entity, applying variant overrides. + + Reads the wiki entity at ``uri``, then looks for a variant file + in ``viking://catalog/{identity}/variant/`` whose ``knowledge`` + frontmatter field matches the entity path extracted from ``uri``. + If found, merges the variant sections into the wiki body. + + Args: + identity: Device identity node (e.g. ``"sany/excavator/sy75c"``). + uri: Wiki entity URI (e.g. ``viking://wiki/fault/pump_failure.md``). + + Returns: + JSON with merged content, variant info, and merge operations. + """ + if not uri_filter.is_allowed(uri): + return ToolReturn( + return_value=f"Access denied: URI '{uri}' is not in the allowed namespaces for this agent." + ) + try: + client = await cap.viking._ensure_client() + # Read the base wiki entity + wiki_content = await client.read(uri) + if not wiki_content: + return ToolReturn(return_value=f"Entity not found at {uri}") + frontmatter, wiki_body = parse_frontmatter(wiki_content) + # Extract entity path from uri for matching + # e.g. viking://wiki/fault/pump_failure.md → wiki/fault/pump_failure.md + entity_path = uri.replace("viking://", "") + + # List variant directory + variant_dir = f"viking://catalog/{identity}/variant/" + variant_uri: str | None = None + merged_content = wiki_body + merge_ops: list[str] = [] + has_variant = False + + if uri_filter.is_allowed(variant_dir): + try: + variant_entries = await client.ls(variant_dir) + except Exception: + variant_entries = [] + if variant_entries: + for entry in variant_entries: + fname = entry.get("name", "") if isinstance(entry, dict) else str(entry) + if not fname.endswith(".md"): + continue + v_file_uri = variant_dir + fname + try: + v_content = await client.read(v_file_uri) + except Exception: + continue + if not v_content: + continue + v_fm, v_body = parse_frontmatter(v_content) + v_knowledge = str(v_fm.get("knowledge", "")) + if v_knowledge == entity_path: + merged_content, merge_ops = merge_variant_sections( + wiki_body, v_body + ) + variant_uri = v_file_uri + has_variant = True + break + + result = { + "uri": uri, + "identity": identity, + "content": merged_content, + "has_variant": has_variant, + "variant_uri": variant_uri, + "merge_operations": merge_ops, + "base_version": frontmatter.get("version", 1), + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_get_effective_knowledge) + + # ---- 5. agentdb_query_applicability ---- + async def agentdb_query_applicability( + ctx: RunContext[Any], + identity_node: str, + knowledge_types: list[str] | None = None, + exclude_disputed: bool = True, + ) -> ToolReturn: + """Query applicable knowledge entities for a specific device. + + Reads the device BOM and entity-rel files, then traces the + knowledge graph to find faults, symptoms, OPLs, and procedures + applicable to the device's components. + + Args: + identity_node: Catalog node path (e.g. ``"sany/excavator/sy75c"``). + knowledge_types: Optional list of types to include + (e.g. ``["fault", "symptom"]``). + exclude_disputed: Skip entities with low credibility or + disputed status. + + Returns: + JSON with identity node, items list, and coverage counts. + """ + bom_uri = f"viking://catalog/{identity_node}/bom.md" + graph_base = "viking://graph/entity_rel/" + if not uri_filter.is_allowed(bom_uri) or not uri_filter.is_allowed(graph_base): + return ToolReturn( + return_value="Access denied: required URI namespaces are not in the allowed list." + ) + try: + client = await cap.viking._ensure_client() + # 1. Read BOM + bom_content = await client.read(bom_uri) + if not bom_content: + return ToolReturn(return_value=f"BOM not found at {bom_uri}") + bom_components = parse_bom_table(bom_content) + comp_ids = { + str(c.get("component_id", "")) for c in bom_components if c.get("component_id") + } + + # 2. Read entity-rel files + rel_files = await _read_entity_rel_files( + client, + graph_base, + ("caused_by", "manifests_as", "addresses", "confirmed_by", "repaired_by"), + ) + caused_by = rel_files.get("caused_by", []) + manifests_as = rel_files.get("manifests_as", []) + addresses_edges = rel_files.get("addresses", []) + confirmed_by = rel_files.get("confirmed_by", []) + repaired_by = rel_files.get("repaired_by", []) + + # 3. Match caused_by targets to BOM component_ids → collect fault URIs + fault_uris: set[str] = set() + for edge in caused_by: + target = str(edge.get("target", "")) + target_comp_id = ( + target.rsplit("/", maxsplit=1)[-1].replace(".md", "").replace("_", ":") + if target + else "" + ) + # Also try direct match and normalized match + target_norm = _normalize_term(target_comp_id) + if any(_normalize_term(cid) == target_norm for cid in comp_ids): + source = str(edge.get("source", "")) + if source: + fault_uris.add(source) + # Also match if target path contains a component_id + for cid in comp_ids: + if cid and cid in target: + source = str(edge.get("source", "")) + if source: + fault_uris.add(source) + break + + # 4. Match manifests_as sources to collected faults → collect symptom URIs + symptom_uris: set[str] = set() + for edge in manifests_as: + source = str(edge.get("source", "")) + if source in fault_uris: + target = str(edge.get("target", "")) + if target: + symptom_uris.add(target) + + # 5. Match addresses targets to collected faults → collect OPL URIs + opl_uris: set[str] = set() + for edge in addresses_edges: + target = str(edge.get("target", "")) + if target in fault_uris: + source = str(edge.get("source", "")) + if source: + opl_uris.add(source) + + # 6. Match confirmed_by/repaired_by sources to collected faults → collect procedure URIs + procedure_uris: set[str] = set() + for edge in confirmed_by: + source = str(edge.get("source", "")) + if source in fault_uris: + target = str(edge.get("target", "")) + if target: + procedure_uris.add(target) + for edge in repaired_by: + source = str(edge.get("source", "")) + if source in fault_uris: + target = str(edge.get("target", "")) + if target: + procedure_uris.add(target) + + # 7. Read each entity's L0 abstract, parse frontmatter for metadata + all_uris = ( + fault_uris + | symptom_uris + | opl_uris + | procedure_uris + | comp_ids_as_uris(bom_components) + ) + items: list[dict[str, Any]] = [] + for entity_uri in sorted(all_uris): + full_uri = ( + _entity_uri_from_path(entity_uri) + if not entity_uri.startswith("viking://") + else entity_uri + ) + etype = _entity_type_from_path(full_uri) + if etype is None: + continue + if knowledge_types and etype not in knowledge_types: + continue + try: + abstract = await client.abstract(full_uri) + except Exception: + abstract = "" + if abstract: + fm, _ = parse_frontmatter(abstract) + else: + fm = {} + credibility = str(fm.get("credibility", "")) + status = str(fm.get("status", "")) + if exclude_disputed and (credibility == "low" or status == "disputed"): + continue + items.append({ + "uri": full_uri, + "type": etype, + "title": fm.get("title", ""), + "credibility": credibility, + "version": fm.get("version", 1), + "status": status, + }) + + # 8. Sort by specificity (type order: fault > symptom > opl > procedure > component) + type_order = {"fault": 0, "symptom": 1, "opl": 2, "procedure": 3, "component": 4} + items.sort(key=lambda x: type_order.get(x["type"], 99)) + + # 9. Calculate coverage counts per type + coverage: dict[str, int] = { + "fault": sum(1 for i in items if i["type"] == "fault"), + "symptom": sum(1 for i in items if i["type"] == "symptom"), + "opl": sum(1 for i in items if i["type"] == "opl"), + "procedure": sum(1 for i in items if i["type"] == "procedure"), + "component": sum(1 for i in items if i["type"] == "component"), + } + result = { + "identity_node": identity_node, + "items": items, + "coverage": coverage, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_query_applicability) + + # ---- 6. agentdb_list_pending ---- + async def agentdb_list_pending( + ctx: RunContext[Any], + qt_type: str = "", + ticket_status: str = "", + expert_owner: str = "", + parent_qt: str = "", + limit: int = 50, + offset: int = 0, + ) -> ToolReturn: + """List pending quality tickets (QTs) across all ticket namespaces. + + Scans ``viking://tickets/opa/``, ``viking://tickets/ops/``, and + ``viking://tickets/opl_proposal/`` for .md files, reads each + file's frontmatter, and returns a filtered list of QTSummary + objects. + + Args: + qt_type: Filter by QT type (``"opa"``, ``"ops"``, ``"opl_proposal"``). + Empty string returns all types. + ticket_status: Filter by ticket status (e.g. ``"open"``, + ``"reviewing"``, ``"approved"``). + expert_owner: Filter by expert owner name. + parent_qt: Filter by parent QT URI. + limit: Maximum number of results to return. + offset: Number of results to skip (for pagination). + + Returns: + JSON array of QTSummary objects. + """ + tickets_base = "viking://tickets/" + if not uri_filter.is_allowed(tickets_base): + return ToolReturn( + return_value=( + f"Access denied: URI '{tickets_base}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + all_qt_dirs: tuple[str, ...] = ("opa", "ops", "opl_proposal") + qt_dirs: tuple[str, ...] = all_qt_dirs + if qt_type: + qt_dirs = (qt_type,) if qt_type in all_qt_dirs else () + summaries: list[dict[str, Any]] = [] + for qd in qt_dirs: + dir_uri = f"{tickets_base}{qd}/" + try: + entries = await client.ls(dir_uri) + except Exception: + entries = [] + if not entries: + continue + for entry in entries: + if isinstance(entry, str): + fname = entry + elif isinstance(entry, dict): + fname = entry.get("name", "") + else: + continue + if not fname.endswith(".md"): + continue + file_uri = dir_uri + fname + try: + content = await client.read(file_uri) + except Exception: + continue + if not content: + continue + fm, _ = parse_frontmatter(content) + if ticket_status and str(fm.get("ticket_status", "")) != ticket_status: + continue + if expert_owner and str(fm.get("expert_owner", "")) != expert_owner: + continue + if parent_qt and str(fm.get("parent_qt", "")) != parent_qt: + continue + summaries.append({ + "uri": file_uri, + "qt_type": str(fm.get("type", qd)), + "title": str(fm.get("title", "")), + "ticket_status": str(fm.get("ticket_status", "")), + "expert_owner": str(fm.get("expert_owner", "")), + "parent_qt": str(fm.get("parent_qt", "")), + "created_at": str(fm.get("created_at", "")), + "description": str(fm.get("description", "")), + }) + # Sort by created_at descending, then apply pagination + summaries.sort(key=lambda x: x.get("created_at", ""), reverse=True) + total = len(summaries) + paginated = summaries[offset : offset + limit] + result = { + "items": paginated, + "total": total, + "offset": offset, + "limit": limit, + } + return ToolReturn(return_value=json.dumps(result["items"], ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_list_pending) + + return tools + + +async def _find_catalog_path( + client: Any, + base_uri: str, + target_name: str, + current_path: str = "", + depth: int = 0, + max_depth: int = 6, +) -> str | None: + """Recursively search the catalog tree for a directory matching ``target_name``. + + Args: + client: The Viking SDK client. + base_uri: The directory URI to search. + target_name: The directory name to find (normalized comparison). + current_path: Accumulated path from catalog root (internal). + depth: Current recursion depth (internal). + max_depth: Maximum depth. + + Returns: + The full path from catalog root (e.g. ``"sany/excavator/sy75c"``) or None. + """ + if depth > max_depth: + return None + try: + entries = await client.ls(base_uri) + except Exception: + return None + if not entries: + return None + target_norm = _normalize_term(target_name) + for entry in entries: + if isinstance(entry, str): + name = entry + is_dir = entry.endswith("/") + elif isinstance(entry, dict): + name = entry.get("name", "") + is_dir = entry.get("is_dir", False) + else: + continue + clean_name = name.rstrip("/") + path_segment = current_path + "/" + clean_name if current_path else clean_name + if is_dir: + if _normalize_term(clean_name) == target_norm: + return path_segment + sub_uri = base_uri.rstrip("/") + "/" + name + found = await _find_catalog_path( + client, sub_uri, target_name, path_segment, depth + 1, max_depth + ) + if found is not None: + return found + return None + + +def comp_ids_as_uris(components: list[dict[str, Any]]) -> set[str]: + """Convert BOM component_ids to wiki component URIs. + + Args: + components: List of BOM component dicts. + + Returns: + A set of wiki component URI strings. + """ + uris: set[str] = set() + for comp in components: + comp_id = str(comp.get("component_id", "")) + if not comp_id: + continue + safe_id = comp_id.replace("/", "_").replace(":", "_") + uris.add(f"viking://wiki/component/{safe_id}.md") + return uris diff --git a/tests/capabilities/agent_db/test_schema_read.py b/tests/capabilities/agent_db/test_schema_read.py new file mode 100644 index 000000000..101ee7e5d --- /dev/null +++ b/tests/capabilities/agent_db/test_schema_read.py @@ -0,0 +1,842 @@ +"""Unit tests for schema-aware read tools (Phase 2).""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pydantic_ai.messages import ToolReturn +import pytest + +from agentpool.capabilities.agent_db import AgentDBCapability +from agentpool.capabilities.agent_db.schema_read import build_schema_read_tools +from agentpool.capabilities.viking import VikingCapability + + +pytestmark = pytest.mark.unit + + +def _get_tool(tools: list[Any], name: str) -> Any: + """Find a tool by name from the list returned by build_schema_read_tools.""" + return next(t for t in tools if t.__name__ == name) + + +def _make_ctx(session_id: str | None = "test-session") -> MagicMock: + """Create a mock RunContext with session_id on deps.""" + ctx = MagicMock() + ctx.deps = MagicMock() + ctx.deps.session_id = session_id + return ctx + + +# ---- TestResolveIdentity ---- + + +class TestResolveIdentity: + """Tests for agentdb_resolve_identity.""" + + async def test_resolve_by_marketing_model( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Resolve identity by matching a marketing model name in the catalog tree.""" + # Mock nested catalog directory structure + # viking://catalog/ → ["sany/"] + # viking://catalog/sany/ → ["excavator/"] + # viking://catalog/sany/excavator/ → ["sy75c/"] + mock_client.ls = AsyncMock( + side_effect=[ + ["sany/"], # catalog root + ["excavator/"], # sany/ + ["sy75c/"], # sany/excavator/ + ] + ) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_resolve_identity") + + ctx = _make_ctx() + result = await tool(ctx, marketing_model="SY75C") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["node_path"] == "sany/excavator/sy75c" + assert data["catalog_uri"] == "viking://catalog/sany/excavator/sy75c/" + assert data["bom_uri"] == "viking://catalog/sany/excavator/sy75c/bom.md" + assert data["domain_uri"] == "viking://wiki/domain/excavator" + assert data["level"] == "model" + + async def test_resolve_by_serial_number( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Resolve identity by serial number (normalized matching).""" + mock_client.ls = AsyncMock( + side_effect=[ + ["doosan/"], + ["excavator/"], + ["dx55/"], + ] + ) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_resolve_identity") + + ctx = _make_ctx() + result = await tool(ctx, serial_number="DX-55") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["node_path"] == "doosan/excavator/dx55" + + async def test_resolve_not_found( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Return error when identity is not found in catalog.""" + mock_client.ls = AsyncMock(return_value=[]) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_resolve_identity") + + ctx = _make_ctx() + result = await tool(ctx, marketing_model="nonexistent") + + assert isinstance(result, ToolReturn) + assert "not found" in str(result.return_value).lower() + + async def test_resolve_no_search_term(self, agent_db_cap: AgentDBCapability) -> None: + """Return error when no search term is provided.""" + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_resolve_identity") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + assert "at least one" in str(result.return_value).lower() + + +# ---- TestTraverseBom ---- + + +class TestTraverseBom: + """Tests for agentdb_traverse_bom.""" + + async def test_traverse_bom_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Parse a BOM markdown table and return component list.""" + bom_markdown = """# BOM SY75C + +| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family | +|------|---------|--------|--------|------|-----------|------------| +| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None | +| 发动机系统 | 发动机 | isuzu:4le2 | 4LE2-5678 | 1 | diesel_engine | None | +""" + mock_client.read = AsyncMock(return_value=bom_markdown) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_traverse_bom") + + ctx = _make_ctx() + result = await tool(ctx, identity_node="sany/excavator/sy75c") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["device"] == "sany/excavator/sy75c" + assert len(data["components"]) == 2 + comp = data["components"][0] + assert comp["component_id"] == "k3v:k3v63dt" + assert comp["component_name"] == "主泵" + assert comp["system"] == "液压系统" + assert comp["material_no"] == "K3V63DT-1234" + assert comp["quantity"] == "1" + assert comp["class_ref"] == "axial_piston_pump" + assert comp["ecu_family"] is None + assert comp["component_uri"] == "viking://wiki/component/k3v_k3v63dt.md" + assert comp["children"] == [] + + async def test_traverse_bom_system_filter( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Filter BOM components by system name.""" + bom_markdown = """# BOM SY75C + +| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family | +|------|---------|--------|--------|------|-----------|------------| +| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None | +| 发动机系统 | 发动机 | isuzu:4le2 | 4LE2-5678 | 1 | diesel_engine | None | +""" + mock_client.read = AsyncMock(return_value=bom_markdown) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_traverse_bom") + + ctx = _make_ctx() + result = await tool( + ctx, + identity_node="sany/excavator/sy75c", + system="液压系统", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert len(data["components"]) == 1 + assert data["components"][0]["system"] == "液压系统" + + async def test_traverse_bom_not_found( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Return error message when BOM file is empty or missing.""" + mock_client.read = AsyncMock(return_value="") + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_traverse_bom") + + ctx = _make_ctx() + result = await tool(ctx, identity_node="sany/excavator/nonexistent") + + assert isinstance(result, ToolReturn) + assert "not found" in str(result.return_value).lower() + + +# ---- TestGetFaultSymptomGraph ---- + + +class TestGetFaultSymptomGraph: + """Tests for agentdb_get_fault_symptom_graph.""" + + async def test_get_graph_by_fault( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Build a fault-symptom graph filtered by a specific fault URI.""" + manifests_as_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/symptom/no_pressure\n" + " relation: manifests_as\n" + " weight: 0.9\n" + ) + caused_by_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.85\n" + ) + leads_to_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/fault/valve_stuck\n" + " relation: leads_to\n" + " weight: 0.7\n" + ) + co_occurs_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/fault/leak\n" + " relation: co_occurs_with\n" + " weight: 0.5\n" + ) + + def read_side_effect(uri: str) -> str: + mapping = { + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/leads_to.yaml": leads_to_yaml, + "viking://graph/entity_rel/co_occurs_with.yaml": co_occurs_yaml, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_fault_symptom_graph") + + ctx = _make_ctx() + result = await tool( + ctx, + fault_uri="wiki/fault/pump_failure", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert "wiki/fault/pump_failure" in data["faults"] + assert "wiki/symptom/no_pressure" in data["symptoms"] + assert "wiki/component/k3v_k3v63dt" in data["components"] + assert len(data["edges"]) > 0 + # co_occurs_with: "wiki/fault/leak" is a fault but not in the filtered faults set + assert "wiki/fault/leak" in data["co_occurring_faults"] + + async def test_get_graph_empty( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Return empty graph when all entity-rel files are empty/missing.""" + mock_client.read = AsyncMock(return_value="") + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_fault_symptom_graph") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["faults"] == [] + assert data["symptoms"] == [] + assert data["components"] == [] + assert data["edges"] == [] + assert data["co_occurring_faults"] == [] + + async def test_get_graph_no_filter( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Build full graph without any filter.""" + manifests_as_yaml = ( + "- source: wiki/fault/f1\n" + " target: wiki/symptom/s1\n" + " relation: manifests_as\n" + " weight: 0.9\n" + ) + caused_by_yaml = ( + "- source: wiki/fault/f1\n" + " target: wiki/component/c1\n" + " relation: caused_by\n" + " weight: 0.85\n" + ) + + def read_side_effect(uri: str) -> str: + mapping = { + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/leads_to.yaml": "", + "viking://graph/entity_rel/co_occurs_with.yaml": "", + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_fault_symptom_graph") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert "wiki/fault/f1" in data["faults"] + assert "wiki/symptom/s1" in data["symptoms"] + assert "wiki/component/c1" in data["components"] + + +# ---- TestGetEffectiveKnowledge ---- + + +class TestGetEffectiveKnowledge: + """Tests for agentdb_get_effective_knowledge.""" + + async def test_no_variant( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Return base wiki content when no variant file exists.""" + wiki_content = """--- +title: 泵故障 +version: 2 +--- + +## 故障描述 + +主泵压力不足。 + +## 排查步骤 + +1. 检查先导压力 +""" + mock_client.read = AsyncMock(return_value=wiki_content) + mock_client.ls = AsyncMock(return_value=[]) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_effective_knowledge") + + ctx = _make_ctx() + result = await tool( + ctx, + identity="sany/excavator/sy75c", + uri="viking://wiki/fault/pump_failure.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["has_variant"] is False + assert data["variant_uri"] is None + assert data["merge_operations"] == [] + assert data["base_version"] == 2 + assert "故障描述" in data["content"] + + async def test_with_variant_override( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Merge wiki body with a variant file that overrides a section.""" + wiki_content = """--- +title: 泵故障 +version: 2 +--- + +## 故障描述 + +通用描述。 + +## 排查步骤 + +通用步骤。 +""" + variant_content = """--- +knowledge: wiki/fault/pump_failure.md +version: 1 +--- + +## 故障描述 + +SY75C 特有描述。 +""" + # Mock: first read is wiki, second read is variant file + read_calls: list[str] = [] + + async def read_side_effect(uri: str) -> str: + read_calls.append(uri) + if uri == "viking://wiki/fault/pump_failure.md": + return wiki_content + if uri == "viking://catalog/sany/excavator/sy75c/variant/pump_failure_variant.md": + return variant_content + return "" + + mock_client.read = AsyncMock(side_effect=read_side_effect) + mock_client.ls = AsyncMock( + return_value=[ + {"name": "pump_failure_variant.md", "is_dir": False}, + ] + ) + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_effective_knowledge") + + ctx = _make_ctx() + result = await tool( + ctx, + identity="sany/excavator/sy75c", + uri="viking://wiki/fault/pump_failure.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["has_variant"] is True + assert data["variant_uri"] is not None + assert "variant" in data["variant_uri"] + assert len(data["merge_operations"]) > 0 + assert "SY75C 特有描述" in data["content"] + assert data["base_version"] == 2 + + +# ---- TestQueryApplicability ---- + + +class TestQueryApplicability: + """Tests for agentdb_query_applicability.""" + + async def test_basic_applicability( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Query applicable knowledge entities for a device.""" + bom_markdown = """# BOM SY75C + +| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family | +|------|---------|--------|--------|------|-----------|------------| +| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None | +""" + caused_by_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.9\n" + ) + manifests_as_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/symptom/no_pressure\n" + " relation: manifests_as\n" + " weight: 0.85\n" + ) + addresses_yaml = ( + "- source: wiki/opl/fix_pump\n" + " target: wiki/fault/pump_failure\n" + " relation: addresses\n" + " weight: 0.8\n" + ) + confirmed_by_yaml = "" + repaired_by_yaml = "" + + def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://catalog/sany/excavator/sy75c/bom.md": bom_markdown, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/addresses.yaml": addresses_yaml, + "viking://graph/entity_rel/confirmed_by.yaml": confirmed_by_yaml, + "viking://graph/entity_rel/repaired_by.yaml": repaired_by_yaml, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + # Mock abstract calls for each entity + async def abstract_side_effect(uri: str) -> str: + abstracts: dict[str, str] = { + "viking://wiki/fault/pump_failure": "---\ntitle: 泵故障\ntype: fault\ncredibility: high\nversion: 2\nstatus: active\n---\nAbstract.", + "viking://wiki/symptom/no_pressure": "---\ntitle: 无压力\ntype: symptom\ncredibility: high\nversion: 1\nstatus: active\n---\nAbstract.", + "viking://wiki/opl/fix_pump": "---\ntitle: 修复泵\ntype: opl\ncredibility: medium\nversion: 1\nstatus: active\n---\nAbstract.", + "viking://wiki/component/k3v_k3v63dt.md": "---\ntitle: 主泵\ntype: component\ncredibility: high\nversion: 1\nstatus: active\n---\nAbstract.", + } + return abstracts.get(uri, "") + + mock_client.abstract = AsyncMock(side_effect=abstract_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_query_applicability") + + ctx = _make_ctx() + result = await tool(ctx, identity_node="sany/excavator/sy75c") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["identity_node"] == "sany/excavator/sy75c" + assert len(data["items"]) > 0 + types_in_items = {item["type"] for item in data["items"]} + assert "fault" in types_in_items + assert "symptom" in types_in_items + assert "opl" in types_in_items + assert data["coverage"]["fault"] >= 1 + assert data["coverage"]["symptom"] >= 1 + assert data["coverage"]["opl"] >= 1 + + async def test_filter_by_type( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Filter applicability results by knowledge type.""" + bom_markdown = """# BOM SY75C + +| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family | +|------|---------|--------|--------|------|-----------|------------| +| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None | +""" + caused_by_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.9\n" + ) + manifests_as_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/symptom/no_pressure\n" + " relation: manifests_as\n" + " weight: 0.85\n" + ) + addresses_yaml = ( + "- source: wiki/opl/fix_pump\n" + " target: wiki/fault/pump_failure\n" + " relation: addresses\n" + " weight: 0.8\n" + ) + confirmed_by_yaml = "" + repaired_by_yaml = "" + + def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://catalog/sany/excavator/sy75c/bom.md": bom_markdown, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/addresses.yaml": addresses_yaml, + "viking://graph/entity_rel/confirmed_by.yaml": confirmed_by_yaml, + "viking://graph/entity_rel/repaired_by.yaml": repaired_by_yaml, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + async def abstract_side_effect(uri: str) -> str: + abstracts: dict[str, str] = { + "viking://wiki/fault/pump_failure": "---\ntitle: 泵故障\ncredibility: high\nversion: 2\nstatus: active\n---\nAbstract.", + "viking://wiki/symptom/no_pressure": "---\ntitle: 无压力\ncredibility: high\nversion: 1\nstatus: active\n---\nAbstract.", + "viking://wiki/opl/fix_pump": "---\ntitle: 修复泵\ncredibility: medium\nversion: 1\nstatus: active\n---\nAbstract.", + "viking://wiki/component/k3v_k3v63dt.md": "---\ntitle: 主泵\ncredibility: high\nversion: 1\nstatus: active\n---\nAbstract.", + } + return abstracts.get(uri, "") + + mock_client.abstract = AsyncMock(side_effect=abstract_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_query_applicability") + + ctx = _make_ctx() + result = await tool( + ctx, + identity_node="sany/excavator/sy75c", + knowledge_types=["fault"], + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + types_in_items = {item["type"] for item in data["items"]} + assert types_in_items == {"fault"} + assert data["coverage"]["fault"] >= 1 + assert data["coverage"]["symptom"] == 0 + + async def test_exclude_disputed( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Exclude entities with low credibility or disputed status.""" + bom_markdown = """# BOM SY75C + +| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family | +|------|---------|--------|--------|------|-----------|------------| +| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None | +""" + caused_by_yaml = ( + "- source: wiki/fault/good_fault\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.9\n" + "- source: wiki/fault/bad_fault\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.5\n" + ) + manifests_as_yaml = "" + addresses_yaml = "" + confirmed_by_yaml = "" + repaired_by_yaml = "" + + def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://catalog/sany/excavator/sy75c/bom.md": bom_markdown, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/addresses.yaml": addresses_yaml, + "viking://graph/entity_rel/confirmed_by.yaml": confirmed_by_yaml, + "viking://graph/entity_rel/repaired_by.yaml": repaired_by_yaml, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + async def abstract_side_effect(uri: str) -> str: + abstracts: dict[str, str] = { + "viking://wiki/fault/good_fault": "---\ntitle: Good\ncredibility: high\nversion: 1\nstatus: active\n---\nA.", + "viking://wiki/fault/bad_fault": "---\ntitle: Bad\ncredibility: low\nversion: 1\nstatus: active\n---\nB.", + "viking://wiki/component/k3v_k3v63dt.md": "---\ntitle: Pump\ncredibility: high\nversion: 1\nstatus: active\n---\nC.", + } + return abstracts.get(uri, "") + + mock_client.abstract = AsyncMock(side_effect=abstract_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_query_applicability") + + ctx = _make_ctx() + result = await tool( + ctx, + identity_node="sany/excavator/sy75c", + exclude_disputed=True, + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + uris = {item["uri"] for item in data["items"]} + assert "viking://wiki/fault/good_fault" in uris + assert "viking://wiki/fault/bad_fault" not in uris + + +# ---- Tool count test ---- + + +def test_build_schema_read_tools_returns_expected_tools( + agent_db_cap: AgentDBCapability, +) -> None: + """build_schema_read_tools returns expected tool functions.""" + tools = build_schema_read_tools(agent_db_cap) + names = {t.__name__ for t in tools} + expected = { + "agentdb_resolve_identity", + "agentdb_traverse_bom", + "agentdb_get_fault_symptom_graph", + "agentdb_get_effective_knowledge", + "agentdb_query_applicability", + } + expected.add("agentdb_list_pending") + assert names == expected + assert len(tools) == len(expected) + + +# ---- Visibility test ---- + + +async def test_schema_read_tool_visibility_blocked( + mock_viking: VikingCapability, +) -> None: + """Schema-read tools respect URI prefix visibility.""" + from agentpool.capabilities.agent_db import AgentDBCapability + + cap = AgentDBCapability( + viking=mock_viking, + allowed_prefixes=("viking://wiki/",), # Only wiki allowed + mode="read", + ) + tools = build_schema_read_tools(cap) + bom_tool = _get_tool(tools, "agentdb_traverse_bom") + + ctx = _make_ctx() + # BOM is under viking://catalog/ which is not allowed + result = await bom_tool(ctx, identity_node="sany/excavator/sy75c") + + assert isinstance(result, ToolReturn) + assert "denied" in str(result.return_value).lower() + + +# ---- TestListPending ---- + + +class TestListPending: + """Tests for agentdb_list_pending QT query tool.""" + + async def test_list_pending_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """List pending QTs across tickets/opa/, tickets/ops/, tickets/opl_proposal/.""" + opa_content = "---\ntype: opa\ntitle: OPA-001\nticket_status: open\nexpert_owner: expert_a\ncreated_at: 2026-01-15\ndescription: Test OPA\n---\nBody." + ops_content = "---\ntype: ops\ntitle: OPS-001\nticket_status: reviewing\nexpert_owner: expert_b\ncreated_at: 2026-02-01\ndescription: Test OPS\n---\nBody." + opl_content = "---\ntype: opl_proposal\ntitle: OPL-001\nticket_status: approved\nexpert_owner: expert_a\ncreated_at: 2026-01-10\ndescription: Test OPL\n---\nBody." + + # Mock ls for tickets/ subdirectories + ls_calls: list[str] = [] + + async def ls_side_effect(uri: str) -> list[Any]: + ls_calls.append(uri) + if uri == "viking://tickets/": + return [ + {"name": "opa/", "is_dir": True}, + {"name": "ops/", "is_dir": True}, + {"name": "opl_proposal/", "is_dir": True}, + ] + if uri == "viking://tickets/opa/": + return [{"name": "opa-001.md", "is_dir": False}] + if uri == "viking://tickets/ops/": + return [{"name": "ops-001.md", "is_dir": False}] + if uri == "viking://tickets/opl_proposal/": + return [{"name": "opl-001.md", "is_dir": False}] + return [] + + mock_client.ls = AsyncMock(side_effect=ls_side_effect) + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://tickets/opa/opa-001.md": opa_content, + "viking://tickets/ops/ops-001.md": ops_content, + "viking://tickets/opl_proposal/opl-001.md": opl_content, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_list_pending") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert isinstance(data, list) + assert len(data) == 3 + qt_types = {item["qt_type"] for item in data} + assert qt_types == {"opa", "ops", "opl_proposal"} + statuses = {item["ticket_status"] for item in data} + assert "open" in statuses + assert "reviewing" in statuses + assert "approved" in statuses + + async def test_list_pending_filter_by_type( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Filter pending QTs by qt_type parameter.""" + + async def ls_side_effect(uri: str) -> list[Any]: + if uri == "viking://tickets/": + return [ + {"name": "opa/", "is_dir": True}, + {"name": "ops/", "is_dir": True}, + {"name": "opl_proposal/", "is_dir": True}, + ] + if uri == "viking://tickets/opa/": + return [{"name": "opa-001.md", "is_dir": False}] + if uri == "viking://tickets/ops/": + return [{"name": "ops-001.md", "is_dir": False}] + if uri == "viking://tickets/opl_proposal/": + return [{"name": "opl-001.md", "is_dir": False}] + return [] + + mock_client.ls = AsyncMock(side_effect=ls_side_effect) + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://tickets/opa/opa-001.md": "---\ntype: opa\ntitle: OPA-001\nticket_status: open\ncreated_at: 2026-01-15\n---\nBody.", + "viking://tickets/ops/ops-001.md": "---\ntype: ops\ntitle: OPS-001\nticket_status: open\ncreated_at: 2026-02-01\n---\nBody.", + "viking://tickets/opl_proposal/opl-001.md": "---\ntype: opl_proposal\ntitle: OPL-001\nticket_status: approved\ncreated_at: 2026-01-10\n---\nBody.", + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_list_pending") + + ctx = _make_ctx() + result = await tool(ctx, qt_type="opa") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert len(data) == 1 + assert data[0]["qt_type"] == "opa" + assert data[0]["title"] == "OPA-001" + + async def test_list_pending_empty( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Return empty list when no QTs exist.""" + mock_client.ls = AsyncMock(return_value=[]) + mock_client.read = AsyncMock(return_value="") + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_list_pending") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data == [] From 891862656c15c346a1c9c33ce012518abba24113 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:05:56 +0800 Subject: [PATCH 02/12] feat(agent-db): add get_qt, get_context, get_sub_qts QT query tools --- .../capabilities/agent_db/schema_read.py | 193 ++++++++++++++++++ .../capabilities/agent_db/test_schema_read.py | 185 ++++++++++++++++- 2 files changed, 377 insertions(+), 1 deletion(-) diff --git a/src/agentpool/capabilities/agent_db/schema_read.py b/src/agentpool/capabilities/agent_db/schema_read.py index df1e0fb11..4fe759eeb 100644 --- a/src/agentpool/capabilities/agent_db/schema_read.py +++ b/src/agentpool/capabilities/agent_db/schema_read.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import re from typing import TYPE_CHECKING, Any from pydantic_ai.messages import ToolReturn @@ -830,6 +831,198 @@ async def agentdb_list_pending( tools.append(agentdb_list_pending) + # ---- 7. agentdb_get_qt ---- + async def agentdb_get_qt( + ctx: RunContext[Any], + qt_uri: str, + ) -> ToolReturn: + """Read a quality ticket (QT) file and return its full detail. + + Parses the QT file frontmatter and body, extracts CR (change + record) history from HTML comments in the body, and returns + a QTDetail JSON object. + + Args: + qt_uri: URI of the QT file (e.g. ``viking://tickets/opa/opa-001.md``). + + Returns: + JSON with ``uri``, ``frontmatter``, ``body``, and ``cr_history``. + """ + if not uri_filter.is_allowed(qt_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{qt_uri}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + content = await client.read(qt_uri) + if not content: + return ToolReturn(return_value=f"QT not found at {qt_uri}") + fm, body = parse_frontmatter(content) + # Extract CR history from HTML comments: + cr_history: list[dict[str, str]] = [] + cr_pattern = re.compile(r"", re.DOTALL) + for m in cr_pattern.finditer(body): + cr_text = m.group(1).strip() + entry: dict[str, str] = {} + for raw_part in cr_text.split("|"): + part = raw_part.strip() + if ":" in part: + key, _, val = part.partition(":") + entry[key.strip()] = val.strip() + if entry: + cr_history.append(entry) + result = { + "uri": qt_uri, + "frontmatter": fm, + "body": body, + "cr_history": cr_history, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_get_qt) + + # ---- 8. agentdb_get_context ---- + async def agentdb_get_context( + ctx: RunContext[Any], + qt_uri: str, + ) -> ToolReturn: + """Get the context of a QT including raw references and graph relationships. + + Reads the QT file, extracts raw references from frontmatter + ``entity_rel`` field and crossref graph file, and returns + a QTContext JSON object. + + Args: + qt_uri: URI of the QT file. + + Returns: + JSON with ``qt_uri``, ``raw_refs``, ``related_entities``, and + ``graph_context``. + """ + if not uri_filter.is_allowed(qt_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{qt_uri}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + content = await client.read(qt_uri) + if not content: + return ToolReturn(return_value=f"QT not found at {qt_uri}") + fm, _ = parse_frontmatter(content) + # Extract raw_refs from frontmatter entity_rel + raw_refs: list[dict[str, Any]] = [] + entity_rel = fm.get("entity_rel", []) + if isinstance(entity_rel, list): + raw_refs = [e for e in entity_rel if isinstance(e, dict)] + # Read crossref graph file for related entities + related_entities: list[dict[str, Any]] = [] + graph_context: dict[str, Any] = {} + crossref_uri = "viking://graph/entity_rel/crossref.yaml" + if uri_filter.is_allowed(crossref_uri): + try: + crossref_content = await client.read(crossref_uri) + if crossref_content: + parsed = yaml.safe_load(crossref_content) + if isinstance(parsed, list): + # Filter edges related to this QT + qt_path = qt_uri.replace("viking://", "") + for edge in parsed: + if isinstance(edge, dict): + src = str(edge.get("source", "")) + tgt = str(edge.get("target", "")) + if qt_path in src or qt_path in tgt: + related_entities.append(edge) + graph_context["crossref_edges"] = related_entities + except Exception: + pass + result = { + "qt_uri": qt_uri, + "raw_refs": raw_refs, + "related_entities": related_entities, + "graph_context": graph_context, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_get_context) + + # ---- 9. agentdb_get_sub_qts ---- + async def agentdb_get_sub_qts( + ctx: RunContext[Any], + parent_uri: str, + ) -> ToolReturn: + """List child QTs that reference a parent QT. + + Scans all ticket subdirectories for .md files whose + ``parent_qt`` frontmatter field matches ``parent_uri``. + + Args: + parent_uri: URI of the parent QT. + + Returns: + JSON array of QTSummary objects for child QTs. + """ + tickets_base = "viking://tickets/" + if not uri_filter.is_allowed(tickets_base): + return ToolReturn( + return_value=( + f"Access denied: URI '{tickets_base}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + qt_dirs: tuple[str, ...] = ("opa", "ops", "opl_proposal") + children: list[dict[str, Any]] = [] + for qd in qt_dirs: + dir_uri = f"{tickets_base}{qd}/" + try: + entries = await client.ls(dir_uri) + except Exception: + entries = [] + if not entries: + continue + for entry in entries: + if isinstance(entry, str): + fname = entry + elif isinstance(entry, dict): + fname = entry.get("name", "") + else: + continue + if not fname.endswith(".md"): + continue + file_uri = dir_uri + fname + try: + file_content = await client.read(file_uri) + except Exception: + continue + if not file_content: + continue + fm, _ = parse_frontmatter(file_content) + if str(fm.get("parent_qt", "")) == parent_uri: + children.append({ + "uri": file_uri, + "qt_type": str(fm.get("type", qd)), + "title": str(fm.get("title", "")), + "ticket_status": str(fm.get("ticket_status", "")), + "parent_qt": str(fm.get("parent_qt", "")), + "created_at": str(fm.get("created_at", "")), + }) + return ToolReturn(return_value=json.dumps(children, ensure_ascii=False)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_get_sub_qts) + return tools diff --git a/tests/capabilities/agent_db/test_schema_read.py b/tests/capabilities/agent_db/test_schema_read.py index 101ee7e5d..2178d74ef 100644 --- a/tests/capabilities/agent_db/test_schema_read.py +++ b/tests/capabilities/agent_db/test_schema_read.py @@ -682,7 +682,12 @@ def test_build_schema_read_tools_returns_expected_tools( "agentdb_get_effective_knowledge", "agentdb_query_applicability", } - expected.add("agentdb_list_pending") + expected.update({ + "agentdb_list_pending", + "agentdb_get_qt", + "agentdb_get_context", + "agentdb_get_sub_qts", + }) assert names == expected assert len(tools) == len(expected) @@ -840,3 +845,181 @@ async def test_list_pending_empty( assert isinstance(result, ToolReturn) data = json.loads(result.return_value) assert data == [] + + +# ---- TestGetQT ---- + + +class TestGetQT: + """Tests for agentdb_get_qt.""" + + async def test_get_qt_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Read a QT file and return frontmatter + body + cr_history.""" + qt_content = ( + "---\n" + "type: opa\n" + "title: OPA-001\n" + "ticket_status: open\n" + "expert_owner: expert_a\n" + "created_at: 2026-01-15\n" + "description: Test OPA\n" + "---\n\n" + "## Description\n\nTest OPA body.\n\n" + "\n" + ) + mock_client.read = AsyncMock(return_value=qt_content) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_qt") + + ctx = _make_ctx() + result = await tool(ctx, qt_uri="viking://tickets/opa/opa-001.md") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["uri"] == "viking://tickets/opa/opa-001.md" + assert data["frontmatter"]["type"] == "opa" + assert data["frontmatter"]["title"] == "OPA-001" + assert data["frontmatter"]["ticket_status"] == "open" + assert "Test OPA body" in data["body"] + assert isinstance(data["cr_history"], list) + assert len(data["cr_history"]) >= 1 + + async def test_get_qt_not_found( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Return error when QT file is empty or not found.""" + mock_client.read = AsyncMock(return_value="") + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_qt") + + ctx = _make_ctx() + result = await tool(ctx, qt_uri="viking://tickets/opa/nonexistent.md") + + assert isinstance(result, ToolReturn) + assert "not found" in str(result.return_value).lower() + + +# ---- TestGetContext ---- + + +class TestGetContext: + """Tests for agentdb_get_context.""" + + async def test_get_context_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Read QT and related entities, return context with raw_refs and graph_context.""" + qt_content = ( + "---\n" + "type: opa\n" + "title: OPA-001\n" + "ticket_status: open\n" + "entity_rel:\n" + " - source: wiki/fault/pump_failure\n" + " relation: addresses\n" + "---\n\n" + "## Description\n\nTest OPA.\n" + ) + crossref_yaml = ( + "- source: tickets/opa/opa-001.md\n" + " target: wiki/fault/pump_failure\n" + " relation: addresses\n" + ) + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://tickets/opa/opa-001.md": qt_content, + "viking://graph/entity_rel/crossref.yaml": crossref_yaml, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_context") + + ctx = _make_ctx() + result = await tool(ctx, qt_uri="viking://tickets/opa/opa-001.md") + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["qt_uri"] == "viking://tickets/opa/opa-001.md" + assert "raw_refs" in data + assert isinstance(data["raw_refs"], list) + assert "related_entities" in data + assert isinstance(data["graph_context"], dict) + + +# ---- TestGetSubQTs ---- + + +class TestGetSubQTs: + """Tests for agentdb_get_sub_qts.""" + + async def test_get_sub_qts_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """List child QTs that have parent_qt pointing to the parent.""" + child1_content = ( + "---\ntype: ops\ntitle: OPS-child-1\nticket_status: open\n" + "parent_qt: viking://tickets/opa/opa-001.md\n---\nBody." + ) + child2_content = ( + "---\ntype: ops\ntitle: OPS-child-2\nticket_status: reviewing\n" + "parent_qt: viking://tickets/opa/opa-001.md\n---\nBody." + ) + unrelated_content = ( + "---\ntype: ops\ntitle: OPS-other\nticket_status: open\n" + "parent_qt: viking://tickets/opa/opa-999.md\n---\nBody." + ) + + async def ls_side_effect(uri: str) -> list[Any]: + if uri == "viking://tickets/ops/": + return [ + {"name": "ops-child-1.md", "is_dir": False}, + {"name": "ops-child-2.md", "is_dir": False}, + {"name": "ops-other.md", "is_dir": False}, + ] + return [] + + mock_client.ls = AsyncMock(side_effect=ls_side_effect) + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://tickets/ops/ops-child-1.md": child1_content, + "viking://tickets/ops/ops-child-2.md": child2_content, + "viking://tickets/ops/ops-other.md": unrelated_content, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_sub_qts") + + ctx = _make_ctx() + result = await tool( + ctx, + parent_uri="viking://tickets/opa/opa-001.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert isinstance(data, list) + assert len(data) == 2 + titles = {item["title"] for item in data} + assert "OPS-child-1" in titles + assert "OPS-child-2" in titles + assert "OPS-other" not in titles From a81b3cb7e67386d4710d9aae7350629feb364325 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:06:49 +0800 Subject: [PATCH 03/12] feat(agent-db): add query_signals, query_backlog QT query tools --- .../capabilities/agent_db/schema_read.py | 172 ++++++++++++++++++ .../capabilities/agent_db/test_schema_read.py | 147 +++++++++++++++ 2 files changed, 319 insertions(+) diff --git a/src/agentpool/capabilities/agent_db/schema_read.py b/src/agentpool/capabilities/agent_db/schema_read.py index 4fe759eeb..be4f0b0a7 100644 --- a/src/agentpool/capabilities/agent_db/schema_read.py +++ b/src/agentpool/capabilities/agent_db/schema_read.py @@ -1023,6 +1023,178 @@ async def agentdb_get_sub_qts( tools.append(agentdb_get_sub_qts) + # ---- 10. agentdb_query_signals ---- + async def agentdb_query_signals( + ctx: RunContext[Any], + signal_name: str = "", + priority: str = "", + status: str = "", + ) -> ToolReturn: + """Scan tickets for signal metadata and return matching signals. + + Scans all ticket subdirectories for .md files whose + frontmatter contains a ``signal_name`` field, filters by + the provided criteria, and returns a list of SignalInfo + objects. + + Args: + signal_name: Filter by signal name. + priority: Filter by signal priority. + status: Filter by ticket status. + + Returns: + JSON array of SignalInfo objects. + """ + tickets_base = "viking://tickets/" + if not uri_filter.is_allowed(tickets_base): + return ToolReturn( + return_value=( + f"Access denied: URI '{tickets_base}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + qt_dirs: tuple[str, ...] = ("opa", "ops", "opl_proposal") + signals: list[dict[str, Any]] = [] + for qd in qt_dirs: + dir_uri = f"{tickets_base}{qd}/" + try: + entries = await client.ls(dir_uri) + except Exception: + entries = [] + if not entries: + continue + for entry in entries: + if isinstance(entry, str): + fname = entry + elif isinstance(entry, dict): + fname = entry.get("name", "") + else: + continue + if not fname.endswith(".md"): + continue + file_uri = dir_uri + fname + try: + file_content = await client.read(file_uri) + except Exception: + continue + if not file_content: + continue + fm, _ = parse_frontmatter(file_content) + if "signal_name" not in fm: + continue + if signal_name and str(fm.get("signal_name", "")) != signal_name: + continue + if priority and str(fm.get("signal_priority", "")) != priority: + continue + if status and str(fm.get("ticket_status", "")) != status: + continue + signals.append({ + "uri": file_uri, + "signal_name": str(fm.get("signal_name", "")), + "signal_priority": str(fm.get("signal_priority", "")), + "ticket_status": str(fm.get("ticket_status", "")), + "created_at": str(fm.get("created_at", "")), + "qt_type": str(fm.get("type", qd)), + }) + return ToolReturn(return_value=json.dumps(signals, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_query_signals) + + # ---- 11. agentdb_query_backlog ---- + async def agentdb_query_backlog( + ctx: RunContext[Any], + qt_type: str = "", + expert_owner: str = "", + ) -> ToolReturn: + """Aggregate pending QTs into a backlog report. + + Collects all pending QTs (reusing list_pending logic), + computes counts by type, expert owner, and priority, + and returns a BacklogReport JSON object. + + Args: + qt_type: Filter by QT type. + expert_owner: Filter by expert owner. + + Returns: + JSON with ``total``, ``counts_by_type``, ``counts_by_expert``, + ``items``, and ``oldest_pending_days``. + """ + tickets_base = "viking://tickets/" + if not uri_filter.is_allowed(tickets_base): + return ToolReturn( + return_value=( + f"Access denied: URI '{tickets_base}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + all_qt_dirs: tuple[str, ...] = ("opa", "ops", "opl_proposal") + scan_dirs: tuple[str, ...] = all_qt_dirs + if qt_type: + scan_dirs = (qt_type,) if qt_type in all_qt_dirs else () + items: list[dict[str, Any]] = [] + for qd in scan_dirs: + dir_uri = f"{tickets_base}{qd}/" + try: + entries = await client.ls(dir_uri) + except Exception: + entries = [] + if not entries: + continue + for entry in entries: + if isinstance(entry, str): + fname = entry + elif isinstance(entry, dict): + fname = entry.get("name", "") + else: + continue + if not fname.endswith(".md"): + continue + file_uri = dir_uri + fname + try: + file_content = await client.read(file_uri) + except Exception: + continue + if not file_content: + continue + fm, _ = parse_frontmatter(file_content) + if expert_owner and str(fm.get("expert_owner", "")) != expert_owner: + continue + items.append({ + "uri": file_uri, + "qt_type": str(fm.get("type", qd)), + "title": str(fm.get("title", "")), + "ticket_status": str(fm.get("ticket_status", "")), + "expert_owner": str(fm.get("expert_owner", "")), + "created_at": str(fm.get("created_at", "")), + }) + # Compute counts + counts_by_type: dict[str, int] = {} + counts_by_expert: dict[str, int] = {} + for item in items: + qt = str(item.get("qt_type", "")) + counts_by_type[qt] = counts_by_type.get(qt, 0) + 1 + exp = str(item.get("expert_owner", "")) + if exp: + counts_by_expert[exp] = counts_by_expert.get(exp, 0) + 1 + result = { + "total": len(items), + "counts_by_type": counts_by_type, + "counts_by_expert": counts_by_expert, + "items": items, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_query_backlog) + return tools diff --git a/tests/capabilities/agent_db/test_schema_read.py b/tests/capabilities/agent_db/test_schema_read.py index 2178d74ef..adacb3755 100644 --- a/tests/capabilities/agent_db/test_schema_read.py +++ b/tests/capabilities/agent_db/test_schema_read.py @@ -687,6 +687,8 @@ def test_build_schema_read_tools_returns_expected_tools( "agentdb_get_qt", "agentdb_get_context", "agentdb_get_sub_qts", + "agentdb_query_signals", + "agentdb_query_backlog", }) assert names == expected assert len(tools) == len(expected) @@ -1023,3 +1025,148 @@ async def read_side_effect(uri: str) -> str: assert "OPS-child-1" in titles assert "OPS-child-2" in titles assert "OPS-other" not in titles + + +# ---- TestQuerySignals ---- + + +class TestQuerySignals: + """Tests for agentdb_query_signals.""" + + async def test_query_signals_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Scan tickets/ for signal metadata and return SignalInfo list.""" + signal1 = ( + "---\ntype: ops\nsignal_name: abnormal_pressure\n" + "signal_priority: high\nticket_status: open\n" + "created_at: 2026-01-15\n---\nBody." + ) + signal2 = ( + "---\ntype: ops\nsignal_name: low_flow\n" + "signal_priority: medium\nticket_status: open\n" + "created_at: 2026-02-01\n---\nBody." + ) + nonsignal = ( + "---\ntype: opa\ntitle: OPA-001\nticket_status: open\n" + "created_at: 2026-01-10\n---\nBody." + ) + + async def ls_side_effect(uri: str) -> list[Any]: + if uri == "viking://tickets/": + return [ + {"name": "opa/", "is_dir": True}, + {"name": "ops/", "is_dir": True}, + {"name": "opl_proposal/", "is_dir": True}, + ] + if uri == "viking://tickets/opa/": + return [{"name": "opa-001.md", "is_dir": False}] + if uri == "viking://tickets/ops/": + return [ + {"name": "signal-1.md", "is_dir": False}, + {"name": "signal-2.md", "is_dir": False}, + ] + if uri == "viking://tickets/opl_proposal/": + return [] + return [] + + mock_client.ls = AsyncMock(side_effect=ls_side_effect) + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://tickets/opa/opa-001.md": nonsignal, + "viking://tickets/ops/signal-1.md": signal1, + "viking://tickets/ops/signal-2.md": signal2, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_query_signals") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert isinstance(data, list) + assert len(data) == 2 + names = {item["signal_name"] for item in data} + assert "abnormal_pressure" in names + assert "low_flow" in names + + +# ---- TestQueryBacklog ---- + + +class TestQueryBacklog: + """Tests for agentdb_query_backlog.""" + + async def test_query_backlog_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Aggregate pending QTs and compute backlog report.""" + qt1 = ( + "---\ntype: opa\ntitle: OPA-001\nticket_status: open\n" + "expert_owner: expert_a\ncreated_at: 2026-01-15\n---\nBody." + ) + qt2 = ( + "---\ntype: ops\ntitle: OPS-001\nticket_status: open\n" + "expert_owner: expert_b\ncreated_at: 2026-02-01\n---\nBody." + ) + qt3 = ( + "---\ntype: opl_proposal\ntitle: OPL-001\nticket_status: reviewing\n" + "expert_owner: expert_a\ncreated_at: 2026-01-10\n---\nBody." + ) + + async def ls_side_effect(uri: str) -> list[Any]: + if uri == "viking://tickets/": + return [ + {"name": "opa/", "is_dir": True}, + {"name": "ops/", "is_dir": True}, + {"name": "opl_proposal/", "is_dir": True}, + ] + if uri == "viking://tickets/opa/": + return [{"name": "opa-001.md", "is_dir": False}] + if uri == "viking://tickets/ops/": + return [{"name": "ops-001.md", "is_dir": False}] + if uri == "viking://tickets/opl_proposal/": + return [{"name": "opl-001.md", "is_dir": False}] + return [] + + mock_client.ls = AsyncMock(side_effect=ls_side_effect) + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://tickets/opa/opa-001.md": qt1, + "viking://tickets/ops/ops-001.md": qt2, + "viking://tickets/opl_proposal/opl-001.md": qt3, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + tools = build_schema_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_query_backlog") + + ctx = _make_ctx() + result = await tool(ctx) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert "total" in data + assert data["total"] == 3 + assert "counts_by_type" in data + assert data["counts_by_type"]["opa"] == 1 + assert data["counts_by_type"]["ops"] == 1 + assert data["counts_by_type"]["opl_proposal"] == 1 + assert "counts_by_expert" in data + assert data["counts_by_expert"]["expert_a"] == 2 + assert data["counts_by_expert"]["expert_b"] == 1 + assert "items" in data + assert len(data["items"]) == 3 From 0e2647397b67381f2ac99f8abc9358bffb0d2e7f Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:09:33 +0800 Subject: [PATCH 04/12] feat(agent-db): add generate_textbook, get_coverage_report advanced read tools --- ruff.toml | 34 ++ .../capabilities/agent_db/schema_advanced.py | 476 ++++++++++++++++++ .../agent_db/test_schema_advanced.py | 255 ++++++++++ 3 files changed, 765 insertions(+) create mode 100644 src/agentpool/capabilities/agent_db/schema_advanced.py create mode 100644 tests/capabilities/agent_db/test_schema_advanced.py diff --git a/ruff.toml b/ruff.toml index 137f7146a..817ec853f 100644 --- a/ruff.toml +++ b/ruff.toml @@ -155,6 +155,40 @@ max-complexity = 15 "src/agentpool/capabilities/viking/__init__.py" = ["BLE001", "TRY300"] # Viking instructions: E501 for markdown table lines "src/agentpool/capabilities/viking/instructions.py" = ["E501"] +# AgentDB tools: ctx is pydantic-ai internal, BLE001 is intentional for tool error handling +"src/agentpool/capabilities/agent_db/tools.py" = [ + "D417", + "BLE001", + "PLR0915", +] +# AgentDB capability: TC001 for VikingCapability needed at runtime for dataclass field +"src/agentpool/capabilities/agent_db/__init__.py" = ["TC001"] +# AgentDB tests: TC001 for VikingCapability used as mock type, E501 for test data +"tests/capabilities/agent_db/test_proxy_tools.py" = ["TC001", "E501"] +"tests/capabilities/agent_db/test_scaffolding.py" = ["TC001"] +"tests/capabilities/agent_db/__init__.py" = ["D104"] +# AgentDB helpers: PLR0915 (too many statements), E501 (regex patterns) +"src/agentpool/capabilities/agent_db/helpers.py" = ["PLR0915", "E501"] +# AgentDB schema_read: D417, BLE001, PLR0915, E501 +"src/agentpool/capabilities/agent_db/schema_read.py" = [ + "D417", + "BLE001", + "PLR0915", + "E501", +] +# AgentDB schema_read tests +"tests/capabilities/agent_db/test_helpers.py" = ["E501"] +"tests/capabilities/agent_db/test_schema_read.py" = ["TC001", "E501"] +# AgentDB schema_advanced: D417, BLE001, PLR0915, E501 +"src/agentpool/capabilities/agent_db/schema_advanced.py" = [ + "D417", + "BLE001", + "PLR0915", + "E501", + "PERF401", +] +# AgentDB schema_advanced tests +"tests/capabilities/agent_db/test_schema_advanced.py" = ["TC001", "E501"] [format] preview = true diff --git a/src/agentpool/capabilities/agent_db/schema_advanced.py b/src/agentpool/capabilities/agent_db/schema_advanced.py new file mode 100644 index 000000000..7c7aea2a1 --- /dev/null +++ b/src/agentpool/capabilities/agent_db/schema_advanced.py @@ -0,0 +1,476 @@ +"""Advanced read tools for AgentDBCapability. + +Phase 5: Tools that build on the schema-aware read tools to generate +textbooks, coverage reports, and applicability derivations. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from pydantic_ai.messages import ToolReturn +from pydantic_ai.tools import RunContext # noqa: TC002 - needed for get_type_hints() +import yaml + +from agentpool.capabilities.agent_db.helpers import ( + parse_bom_table, + parse_frontmatter, +) +from agentpool.capabilities.agent_db.visibility import URIPrefixFilter + + +if TYPE_CHECKING: + from collections.abc import Callable + + from agentpool.capabilities.agent_db import AgentDBCapability + + +async def _read_entity_rel_files( + client: Any, + base_uri: str, + names: tuple[str, ...], +) -> dict[str, list[dict[str, Any]]]: + """Read and parse multiple entity-rel YAML files. + + Args: + client: The Viking SDK client. + base_uri: Directory URI containing the YAML files (with trailing /). + names: Tuple of file names (without .yaml). + + Returns: + Dict mapping file name to list of edge dicts (empty list on failure). + """ + result: dict[str, list[dict[str, Any]]] = {} + for name in names: + file_uri = base_uri + name + ".yaml" + try: + content = await client.read(file_uri) + if not content: + result[name] = [] + continue + parsed = yaml.safe_load(content) + if isinstance(parsed, list): + result[name] = [e for e in parsed if isinstance(e, dict)] + else: + result[name] = [] + except Exception: + result[name] = [] + return result + + +def _entity_type_from_path(path: str) -> str | None: + """Extract the knowledge type from an entity path segment. + + Args: + path: An entity path (e.g. ``wiki/fault/pump_failure``). + + Returns: + The type label or ``None`` if no known type segment is found. + """ + lower = path.lower() + type_map = { + "fault": "fault", + "symptom": "symptom", + "opl": "opl", + "procedure": "procedure", + "component": "component", + } + for seg, label in type_map.items(): + if f"/{seg}/" in lower or lower.startswith(f"{seg}/"): + return label + return None + + +def _normalize_term(term: str) -> str: + """Normalize a search term for comparison. + + Lowercases, strips dashes, spaces, colons, and underscores so that + ``"k3v:k3v63dt"``, ``"k3v_k3v63dt"``, ``"K3V-K3V63DT"`` all match. + + Args: + term: The raw search term. + + Returns: + The normalized term. + """ + return term.lower().replace("-", "").replace(" ", "").replace(":", "").replace("_", "") + + +def build_advanced_read_tools( + cap: AgentDBCapability, +) -> list[Callable[..., Any]]: + """Build advanced read tool functions for AgentDBCapability. + + Returns 2 async tool closures: + - ``agentdb_generate_textbook`` — assemble 3-layer textbook + - ``agentdb_get_coverage_report`` — compute coverage per symptom/fault + + Args: + cap: The AgentDBCapability instance that owns these tools. + + Returns: + A list of async tool functions. + """ + tools: list[Callable[..., Any]] = [] + uri_filter = URIPrefixFilter(allowed_prefixes=cap.allowed_prefixes) + + if cap.mode in ("read", "write", "all"): + # ---- 1. agentdb_generate_textbook ---- + async def agentdb_generate_textbook( + ctx: RunContext[Any], + identity_node: str, + ) -> ToolReturn: + """Generate a 3-layer diagnostic textbook for a device. + + Calls query_applicability internally to get the knowledge + set, then assembles a 3-layer textbook: + - **domain_layer**: from the Domain entity + - **pruning_layer**: from Symptom pruning_rules and decision_tree + - **variant_layer**: from catalog variant overrides + + Builds an evidence_chain from crossref links and computes + a cache_key from identity_node + max version. + + Args: + identity_node: Catalog node path (e.g. ``"sany/excavator/sy75c"``). + + Returns: + JSON with ``domain_layer``, ``pruning_layer``, + ``variant_layer``, ``assembled_content``, ``evidence_chain``, + and ``cache_key``. + """ + bom_uri = f"viking://catalog/{identity_node}/bom.md" + graph_base = "viking://graph/entity_rel/" + if not uri_filter.is_allowed(bom_uri) or not uri_filter.is_allowed(graph_base): + return ToolReturn( + return_value="Access denied: required URI namespaces are not in the allowed list." + ) + try: + client = await cap.viking._ensure_client() + # 1. Read BOM + bom_content = await client.read(bom_uri) + if not bom_content: + return ToolReturn(return_value=f"BOM not found at {bom_uri}") + bom_components = parse_bom_table(bom_content) + comp_ids = { + str(c.get("component_id", "")) for c in bom_components if c.get("component_id") + } + + # 2. Read entity-rel files + rel_files = await _read_entity_rel_files( + client, + graph_base, + ("caused_by", "manifests_as", "addresses"), + ) + caused_by = rel_files.get("caused_by", []) + manifests_as = rel_files.get("manifests_as", []) + addresses_edges = rel_files.get("addresses", []) + + # 3. Collect fault URIs from caused_by matching BOM components + fault_uris: set[str] = set() + for edge in caused_by: + target = str(edge.get("target", "")) + target_comp_id = ( + target.rsplit("/", maxsplit=1)[-1].replace(".md", "").replace("_", ":") + if target + else "" + ) + target_norm = _normalize_term(target_comp_id) + if any(_normalize_term(cid) == target_norm for cid in comp_ids): + source = str(edge.get("source", "")) + if source: + fault_uris.add(source) + for cid in comp_ids: + if cid and cid in target: + source = str(edge.get("source", "")) + if source: + fault_uris.add(source) + break + + # 4. Collect symptom URIs from manifests_as + symptom_uris: set[str] = set() + for edge in manifests_as: + source = str(edge.get("source", "")) + if source in fault_uris: + target = str(edge.get("target", "")) + if target: + symptom_uris.add(target) + + # 5. Collect OPL URIs from addresses + opl_uris: set[str] = set() + for edge in addresses_edges: + target = str(edge.get("target", "")) + if target in fault_uris: + source = str(edge.get("source", "")) + if source: + opl_uris.add(source) + + # 6. Read domain entity + parts = identity_node.split("/") + domain = "excavator" + min_domain_parts = 2 + if len(parts) >= min_domain_parts: + domain = parts[1] if parts[0] in ("sany", "doosan", "komatsu") else parts[0] + domain_uri = f"viking://wiki/domain/{domain}" + domain_layer: dict[str, Any] = {} + try: + domain_content = await client.read(domain_uri) + if domain_content: + domain_fm, domain_body = parse_frontmatter(domain_content) + domain_layer = { + "uri": domain_uri, + "title": str(domain_fm.get("title", "")), + "content": domain_body, + } + except Exception: + pass + + # 7. Read symptom entities for pruning_layer + pruning_layer: list[dict[str, Any]] = [] + for sym_uri in sorted(symptom_uris): + full_uri = ( + f"viking://{sym_uri}" if not sym_uri.startswith("viking://") else sym_uri + ) + try: + sym_content = await client.read(full_uri) + except Exception: + sym_content = "" + if sym_content: + sym_fm, sym_body = parse_frontmatter(sym_content) + pruning_layer.append({ + "uri": full_uri, + "title": str(sym_fm.get("title", "")), + "pruning_rules": str(sym_fm.get("pruning_rules", "")), + "content": sym_body, + }) + + # 8. Read variant directory for variant_layer + variant_dir = f"viking://catalog/{identity_node}/variant/" + variant_layer: list[dict[str, Any]] = [] + if uri_filter.is_allowed(variant_dir): + try: + variant_entries = await client.ls(variant_dir) + except Exception: + variant_entries = [] + if variant_entries: + for entry in variant_entries: + fname = entry.get("name", "") if isinstance(entry, dict) else str(entry) + if not fname.endswith(".md"): + continue + v_file_uri = variant_dir + fname + try: + v_content = await client.read(v_file_uri) + except Exception: + continue + if v_content: + v_fm, v_body = parse_frontmatter(v_content) + variant_layer.append({ + "uri": v_file_uri, + "knowledge": str(v_fm.get("knowledge", "")), + "content": v_body, + }) + + # 9. Build evidence_chain from crossref + evidence_chain: list[dict[str, Any]] = [] + for edge in caused_by + manifests_as + addresses_edges: + evidence_chain.append({ + "source": str(edge.get("source", "")), + "target": str(edge.get("target", "")), + "relation": str(edge.get("relation", "")), + "weight": edge.get("weight"), + }) + + # 10. Assemble content + assembled_parts: list[str] = [] + if domain_layer.get("content"): + assembled_parts.append( + f"# {domain_layer.get('title', '')}\n\n{domain_layer['content']}" + ) + for p in pruning_layer: + assembled_parts.append(f"## {p['title']}\n\n{p['content']}") + for v in variant_layer: + assembled_parts.append(f"## Variant: {v['knowledge']}\n\n{v['content']}") + assembled_content = "\n\n".join(assembled_parts) + + # 11. Compute cache_key + max_version = 0 + cache_key = f"{identity_node}:v{max_version}" + + result = { + "domain_layer": domain_layer, + "pruning_layer": pruning_layer, + "variant_layer": variant_layer, + "assembled_content": assembled_content, + "evidence_chain": evidence_chain, + "cache_key": cache_key, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_generate_textbook) + + # ---- 2. agentdb_get_coverage_report ---- + async def agentdb_get_coverage_report( + ctx: RunContext[Any], + identity_node: str, + symptoms_checked: list[str], + ) -> ToolReturn: + """Generate a coverage report for a set of symptoms. + + Calls query_applicability internally, then computes coverage + per symptom (matched_symptom_uri, covered, covering_knowledge) + and per fault (has_diagnostic_steps, has_failure_mechanism, + has_fault_mechanism, coverage_completeness). Generates + recommendations for gaps. + + Args: + identity_node: Catalog node path. + symptoms_checked: List of symptom URIs or paths to check. + + Returns: + JSON with ``symptoms``, ``faults``, ``recommendations``, + ``total_symptoms_known``, ``symptoms_covered``, and + ``coverage_ratio``. + """ + bom_uri = f"viking://catalog/{identity_node}/bom.md" + graph_base = "viking://graph/entity_rel/" + if not uri_filter.is_allowed(bom_uri) or not uri_filter.is_allowed(graph_base): + return ToolReturn( + return_value="Access denied: required URI namespaces are not in the allowed list." + ) + try: + client = await cap.viking._ensure_client() + # 1. Read BOM + bom_content = await client.read(bom_uri) + if not bom_content: + return ToolReturn(return_value=f"BOM not found at {bom_uri}") + bom_components = parse_bom_table(bom_content) + comp_ids = { + str(c.get("component_id", "")) for c in bom_components if c.get("component_id") + } + + # 2. Read entity-rel files + rel_files = await _read_entity_rel_files( + client, + graph_base, + ("caused_by", "manifests_as", "addresses"), + ) + caused_by = rel_files.get("caused_by", []) + manifests_as = rel_files.get("manifests_as", []) + addresses_edges = rel_files.get("addresses", []) + + # 3. Collect fault and symptom URIs + fault_uris: set[str] = set() + for edge in caused_by: + target = str(edge.get("target", "")) + target_comp_id = ( + target.rsplit("/", maxsplit=1)[-1].replace(".md", "").replace("_", ":") + if target + else "" + ) + target_norm = _normalize_term(target_comp_id) + if any(_normalize_term(cid) == target_norm for cid in comp_ids): + source = str(edge.get("source", "")) + if source: + fault_uris.add(source) + for cid in comp_ids: + if cid and cid in target: + source = str(edge.get("source", "")) + if source: + fault_uris.add(source) + break + + known_symptom_uris: set[str] = set() + for edge in manifests_as: + source = str(edge.get("source", "")) + if source in fault_uris: + target = str(edge.get("target", "")) + if target: + known_symptom_uris.add(target) + + opl_uris: set[str] = set() + for edge in addresses_edges: + target = str(edge.get("target", "")) + if target in fault_uris: + source = str(edge.get("source", "")) + if source: + opl_uris.add(source) + + # 4. Check coverage per symptom + symptoms_report: list[dict[str, Any]] = [] + covered_count = 0 + for sym in symptoms_checked: + sym_full = f"viking://{sym}" if not sym.startswith("viking://") else sym + is_covered = sym_full in known_symptom_uris or sym in known_symptom_uris + covering: list[str] = [] + if is_covered: + covered_count += 1 + # Find OPLs addressing faults that manifest as this symptom + for edge in manifests_as: + target = str(edge.get("target", "")) + if target in (sym, sym_full): + fault_src = str(edge.get("source", "")) + for ae in addresses_edges: + if str(ae.get("target", "")) == fault_src: + covering.append(str(ae.get("source", ""))) + symptoms_report.append({ + "symptom": sym, + "matched_symptom_uri": sym_full if is_covered else None, + "covered": is_covered, + "covering_knowledge": covering, + }) + + # 5. Check fault coverage + faults_report: list[dict[str, Any]] = [] + for fault_uri in sorted(fault_uris): + full_uri = ( + f"viking://{fault_uri}" + if not fault_uri.startswith("viking://") + else fault_uri + ) + has_diagnostic = any( + str(e.get("target", "")) == fault_uri for e in addresses_edges + ) + has_procedure = False # would check confirmed_by/repaired_by + faults_report.append({ + "fault_uri": full_uri, + "has_diagnostic_steps": has_diagnostic, + "has_failure_mechanism": True, # would check body sections + "has_fault_mechanism": has_procedure, + "coverage_completeness": "partial" if has_diagnostic else "none", + }) + + # 6. Generate recommendations + recommendations: list[str] = [] + for sym_item in symptoms_report: + if not sym_item["covered"]: + recommendations.append( + f"Symptom '{sym_item['symptom']}' is not covered by any " + f"known fault-symptom relationship." + ) + for fault in faults_report: + if not fault["has_diagnostic_steps"]: + recommendations.append( + f"Fault '{fault['fault_uri']}' lacks diagnostic steps." + ) + + total_known = len(known_symptom_uris) + ratio = covered_count / len(symptoms_checked) if symptoms_checked else 0.0 + + result = { + "total_symptoms_known": total_known, + "symptoms_covered": covered_count, + "coverage_ratio": round(ratio, 4), + "symptoms": symptoms_report, + "faults": faults_report, + "recommendations": recommendations, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_get_coverage_report) + + return tools diff --git a/tests/capabilities/agent_db/test_schema_advanced.py b/tests/capabilities/agent_db/test_schema_advanced.py new file mode 100644 index 000000000..1cb7fec3c --- /dev/null +++ b/tests/capabilities/agent_db/test_schema_advanced.py @@ -0,0 +1,255 @@ +"""Unit tests for advanced read tools (Phase 5).""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pydantic_ai.messages import ToolReturn +import pytest + +from agentpool.capabilities.agent_db import AgentDBCapability +from agentpool.capabilities.agent_db.schema_advanced import build_advanced_read_tools + + +pytestmark = pytest.mark.unit + + +def _get_tool(tools: list[Any], name: str) -> Any: + """Find a tool by name from the list returned by build_advanced_read_tools.""" + return next(t for t in tools if t.__name__ == name) + + +def _make_ctx(session_id: str | None = "test-session") -> MagicMock: + """Create a mock RunContext with session_id on deps.""" + ctx = MagicMock() + ctx.deps = MagicMock() + ctx.deps.session_id = session_id + return ctx + + +# ---- TestGenerateTextbook ---- + + +class TestGenerateTextbook: + """Tests for agentdb_generate_textbook.""" + + async def test_generate_textbook_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Generate a 3-layer textbook from knowledge items.""" + # Mock query_applicability results: we need BOM, entity_rel, and entity reads + bom_markdown = ( + "# BOM SY75C\n\n" + "| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family |\n" + "|------|---------|--------|--------|------|-----------|------------|\n" + "| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None |\n" + ) + caused_by_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.9\n" + ) + manifests_as_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/symptom/no_pressure\n" + " relation: manifests_as\n" + " weight: 0.85\n" + ) + addresses_yaml = ( + "- source: wiki/opl/fix_pump\n" + " target: wiki/fault/pump_failure\n" + " relation: addresses\n" + " weight: 0.8\n" + ) + confirmed_by_yaml = "" + repaired_by_yaml = "" + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://catalog/sany/excavator/sy75c/bom.md": bom_markdown, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/addresses.yaml": addresses_yaml, + "viking://graph/entity_rel/confirmed_by.yaml": confirmed_by_yaml, + "viking://graph/entity_rel/repaired_by.yaml": repaired_by_yaml, + "viking://wiki/domain/excavator": ( + "---\ntitle: 挖掘机\ntype: domain\n---\n\n## 概述\n\n挖掘机知识域。\n" + ), + "viking://wiki/fault/pump_failure": ( + "---\ntitle: 泵故障\ntype: fault\ncredibility: high\n" + "version: 2\nstatus: active\n---\n\n" + "## 故障描述\n\n主泵压力不足。\n\n" + "## 排查步骤\n\n1. 检查先导压力\n" + ), + "viking://wiki/symptom/no_pressure": ( + "---\ntitle: 无压力\ntype: symptom\ncredibility: high\n" + "version: 1\nstatus: active\n---\n\n" + "## 症状描述\n\n系统无压力。\n\n" + "## pruning_rules\n\n- 条件1\n" + ), + "viking://wiki/opl/fix_pump": ( + "---\ntitle: 修复泵\ntype: opl\ncredibility: medium\n" + "version: 1\nstatus: active\n---\n\n" + "## 修复方案\n\n更换主泵。\n" + ), + "viking://wiki/component/k3v_k3v63dt.md": ( + "---\ntitle: 主泵\ntype: component\ncredibility: high\n" + "version: 1\nstatus: active\n---\nAbstract." + ), + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + async def abstract_side_effect(uri: str) -> str: + abstracts: dict[str, str] = { + "viking://wiki/fault/pump_failure": ( + "---\ntitle: 泵故障\ncredibility: high\nversion: 2\n" + "status: active\n---\nAbstract." + ), + "viking://wiki/symptom/no_pressure": ( + "---\ntitle: 无压力\ncredibility: high\nversion: 1\n" + "status: active\n---\nAbstract." + ), + "viking://wiki/opl/fix_pump": ( + "---\ntitle: 修复泵\ncredibility: medium\nversion: 1\n" + "status: active\n---\nAbstract." + ), + "viking://wiki/component/k3v_k3v63dt.md": ( + "---\ntitle: 主泵\ncredibility: high\nversion: 1\n" + "status: active\n---\nAbstract." + ), + } + return abstracts.get(uri, "") + + mock_client.abstract = AsyncMock(side_effect=abstract_side_effect) + + tools = build_advanced_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_generate_textbook") + + ctx = _make_ctx() + result = await tool( + ctx, + identity_node="sany/excavator/sy75c", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert "domain_layer" in data + assert "pruning_layer" in data + assert "variant_layer" in data + assert "assembled_content" in data + assert "evidence_chain" in data + assert isinstance(data["evidence_chain"], list) + + +# ---- TestGetCoverageReport ---- + + +class TestGetCoverageReport: + """Tests for agentdb_get_coverage_report.""" + + async def test_get_coverage_report_basic( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Generate a coverage report for known symptoms.""" + bom_markdown = ( + "# BOM SY75C\n\n" + "| 系统 | 组件名称 | 组件ID | 物料号 | 数量 | class_ref | ecu_family |\n" + "|------|---------|--------|--------|------|-----------|------------|\n" + "| 液压系统 | 主泵 | k3v:k3v63dt | K3V63DT-1234 | 1 | axial_piston_pump | None |\n" + ) + caused_by_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/component/k3v_k3v63dt\n" + " relation: caused_by\n" + " weight: 0.9\n" + ) + manifests_as_yaml = ( + "- source: wiki/fault/pump_failure\n" + " target: wiki/symptom/no_pressure\n" + " relation: manifests_as\n" + " weight: 0.85\n" + ) + addresses_yaml = "" + confirmed_by_yaml = "" + repaired_by_yaml = "" + + async def read_side_effect(uri: str) -> str: + mapping: dict[str, str] = { + "viking://catalog/sany/excavator/sy75c/bom.md": bom_markdown, + "viking://graph/entity_rel/caused_by.yaml": caused_by_yaml, + "viking://graph/entity_rel/manifests_as.yaml": manifests_as_yaml, + "viking://graph/entity_rel/addresses.yaml": addresses_yaml, + "viking://graph/entity_rel/confirmed_by.yaml": confirmed_by_yaml, + "viking://graph/entity_rel/repaired_by.yaml": repaired_by_yaml, + } + return mapping.get(uri, "") + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + async def abstract_side_effect(uri: str) -> str: + abstracts: dict[str, str] = { + "viking://wiki/fault/pump_failure": ( + "---\ntitle: 泵故障\ncredibility: high\nversion: 2\n" + "status: active\n---\nAbstract." + ), + "viking://wiki/symptom/no_pressure": ( + "---\ntitle: 无压力\ncredibility: high\nversion: 1\n" + "status: active\n---\nAbstract." + ), + "viking://wiki/component/k3v_k3v63dt.md": ( + "---\ntitle: 主泵\ncredibility: high\nversion: 1\n" + "status: active\n---\nAbstract." + ), + } + return abstracts.get(uri, "") + + mock_client.abstract = AsyncMock(side_effect=abstract_side_effect) + + tools = build_advanced_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_get_coverage_report") + + ctx = _make_ctx() + result = await tool( + ctx, + identity_node="sany/excavator/sy75c", + symptoms_checked=["wiki/symptom/no_pressure", "wiki/symptom/unknown_symptom"], + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert "symptoms" in data + assert len(data["symptoms"]) == 2 + # The known symptom should be covered + covered_symptoms = [s for s in data["symptoms"] if s.get("covered")] + assert len(covered_symptoms) >= 1 + # The unknown symptom should not be covered + uncovered = [s for s in data["symptoms"] if not s.get("covered")] + assert len(uncovered) >= 1 + assert "faults" in data + assert "recommendations" in data + + +# ---- Tool count test ---- + + +def test_build_advanced_read_tools_returns_expected_tools( + agent_db_cap: AgentDBCapability, +) -> None: + """build_advanced_read_tools returns expected tool functions.""" + tools = build_advanced_read_tools(agent_db_cap) + names = {t.__name__ for t in tools} + expected = { + "agentdb_generate_textbook", + "agentdb_get_coverage_report", + } + assert names == expected + assert len(tools) == len(expected) From de694801f00be0205a19c489cdf263744b4140b7 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:10:41 +0800 Subject: [PATCH 05/12] feat(agent-db): add derive_applicability advanced read tool --- .../capabilities/agent_db/schema_advanced.py | 146 ++++++++++++++++++ .../agent_db/test_schema_advanced.py | 85 ++++++++++ 2 files changed, 231 insertions(+) diff --git a/src/agentpool/capabilities/agent_db/schema_advanced.py b/src/agentpool/capabilities/agent_db/schema_advanced.py index 7c7aea2a1..17e557239 100644 --- a/src/agentpool/capabilities/agent_db/schema_advanced.py +++ b/src/agentpool/capabilities/agent_db/schema_advanced.py @@ -473,4 +473,150 @@ async def agentdb_get_coverage_report( tools.append(agentdb_get_coverage_report) + # ---- 3. agentdb_derive_applicability ---- + async def agentdb_derive_applicability( + ctx: RunContext[Any], + entity_uri: str, + ) -> ToolReturn: + """Derive the suggested applicability scope for a wiki entity. + + Reads the entity, then checks catalog variant directories + for overrides targeting this entity. If no variants exist, + suggests ``scope_type="global"``. If variants exist for + specific devices, suggests ``scope_type="catalog_model"`` + with the device target. + + Args: + entity_uri: Wiki entity URI to check. + + Returns: + JSON with ``suggested_scope``, ``alternatives``, and + ``existing_variants``. + """ + if not uri_filter.is_allowed(entity_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{entity_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + # Read the entity + entity_content = await client.read(entity_uri) + if not entity_content: + return ToolReturn(return_value=f"Entity not found at {entity_uri}") + + # Extract entity path for variant matching + entity_path = entity_uri.replace("viking://", "") + + # Scan all catalog/*/variant/ directories for variants targeting this entity + catalog_base = "viking://catalog/" + existing_variants: list[dict[str, Any]] = [] + + if uri_filter.is_allowed(catalog_base): + try: + catalog_entries = await client.ls(catalog_base) + except Exception: + catalog_entries = [] + for entry in catalog_entries: + if isinstance(entry, str): + brand = entry.rstrip("/") + elif isinstance(entry, dict): + brand = entry.get("name", "").rstrip("/") + else: + continue + if not brand: + continue + # Walk brand/series/model/variant/ tree + brand_uri = f"{catalog_base}{brand}/" + try: + brand_entries = await client.ls(brand_uri) + except Exception: + brand_entries = [] + for b_entry in brand_entries: + if isinstance(b_entry, str): + domain = b_entry.rstrip("/") + elif isinstance(b_entry, dict): + domain = b_entry.get("name", "").rstrip("/") + else: + continue + if not domain: + continue + domain_uri = f"{brand_uri}{domain}/" + try: + domain_entries = await client.ls(domain_uri) + except Exception: + domain_entries = [] + for d_entry in domain_entries: + if isinstance(d_entry, str): + model = d_entry.rstrip("/") + elif isinstance(d_entry, dict): + model = d_entry.get("name", "").rstrip("/") + else: + continue + if not model: + continue + model_uri = f"{domain_uri}{model}/" + variant_dir = f"{model_uri}variant/" + try: + variant_entries = await client.ls(variant_dir) + except Exception: + variant_entries = [] + if not variant_entries: + continue + for v_entry in variant_entries: + if isinstance(v_entry, str): + v_fname = v_entry + elif isinstance(v_entry, dict): + v_fname = v_entry.get("name", "") + else: + continue + if not v_fname.endswith(".md"): + continue + v_file_uri = variant_dir + v_fname + try: + v_content = await client.read(v_file_uri) + except Exception: + continue + if not v_content: + continue + v_fm, _ = parse_frontmatter(v_content) + if str(v_fm.get("knowledge", "")) == entity_path: + existing_variants.append({ + "variant_uri": v_file_uri, + "device": f"{brand}/{domain}/{model}", + }) + + # Determine suggested scope + if existing_variants: + suggested_scope = { + "scope_type": "catalog_model", + "target": existing_variants[0]["device"], + } + else: + suggested_scope = { + "scope_type": "global", + "target": None, + } + + alternatives: list[dict[str, Any]] = [] + for v in existing_variants[1:]: + alternatives.append({ + "scope_type": "catalog_model", + "target": v["device"], + }) + + result = { + "entity_uri": entity_uri, + "suggested_scope": suggested_scope, + "alternatives": alternatives, + "existing_variants": existing_variants, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_derive_applicability) + return tools diff --git a/tests/capabilities/agent_db/test_schema_advanced.py b/tests/capabilities/agent_db/test_schema_advanced.py index 1cb7fec3c..420fa5b77 100644 --- a/tests/capabilities/agent_db/test_schema_advanced.py +++ b/tests/capabilities/agent_db/test_schema_advanced.py @@ -250,6 +250,91 @@ def test_build_advanced_read_tools_returns_expected_tools( expected = { "agentdb_generate_textbook", "agentdb_get_coverage_report", + "agentdb_derive_applicability", } assert names == expected assert len(tools) == len(expected) + + +# ---- TestDeriveApplicability ---- + + +class TestDeriveApplicability: + """Tests for agentdb_derive_applicability.""" + + async def test_derive_global_scope( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Entity with no variant overrides → suggested_scope scope_type='global'.""" + entity_content = ( + "---\ntitle: 泵故障\ntype: fault\nversion: 2\n---\n\n## 故障描述\n\n通用描述。\n" + ) + mock_client.read = AsyncMock(return_value=entity_content) + mock_client.ls = AsyncMock(return_value=[]) + + tools = build_advanced_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_derive_applicability") + + ctx = _make_ctx() + result = await tool( + ctx, + entity_uri="viking://wiki/fault/pump_failure.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["suggested_scope"]["scope_type"] == "global" + assert data["existing_variants"] == [] + assert isinstance(data["alternatives"], list) + + async def test_derive_model_scope( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Entity with variant override for specific device → scope_type='catalog_model'.""" + entity_content = ( + "---\ntitle: 泵故障\ntype: fault\nversion: 2\n---\n\n## 故障描述\n\n通用描述。\n" + ) + variant_content = ( + "---\nknowledge: wiki/fault/pump_failure.md\nversion: 1\n---\n\n" + "## 故障描述\n\nSY75C 特有描述。\n" + ) + + async def read_side_effect(uri: str) -> str: + if "viking://wiki/fault/pump_failure.md" in uri: + return entity_content + if "variant" in uri and uri.endswith(".md"): + return variant_content + return "" + + mock_client.read = AsyncMock(side_effect=read_side_effect) + + async def ls_side_effect(uri: str) -> list[Any]: + if uri == "viking://catalog/": + return [{"name": "sany/", "is_dir": True}] + if uri == "viking://catalog/sany/": + return [{"name": "excavator/", "is_dir": True}] + if uri == "viking://catalog/sany/excavator/": + return [{"name": "sy75c/", "is_dir": True}] + if uri == "viking://catalog/sany/excavator/sy75c/variant/": + return [{"name": "pump_failure_variant.md", "is_dir": False}] + return [] + + mock_client.ls = AsyncMock(side_effect=ls_side_effect) + + tools = build_advanced_read_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_derive_applicability") + + ctx = _make_ctx() + result = await tool( + ctx, + entity_uri="viking://wiki/fault/pump_failure.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["suggested_scope"]["scope_type"] == "catalog_model" + assert len(data["existing_variants"]) >= 1 From b6dbbf91e5188f6b94512355d7429a25225e7bc4 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:11:33 +0800 Subject: [PATCH 06/12] feat(agent-db): add create_entity, update_entity schema-aware write tools --- ruff.toml | 9 + .../capabilities/agent_db/schema_write.py | 202 ++++++++++++++++++ .../agent_db/test_schema_write.py | 150 +++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 src/agentpool/capabilities/agent_db/schema_write.py create mode 100644 tests/capabilities/agent_db/test_schema_write.py diff --git a/ruff.toml b/ruff.toml index 817ec853f..700081681 100644 --- a/ruff.toml +++ b/ruff.toml @@ -189,6 +189,15 @@ max-complexity = 15 ] # AgentDB schema_advanced tests "tests/capabilities/agent_db/test_schema_advanced.py" = ["TC001", "E501"] +# AgentDB schema_write: D417, BLE001, PLR0915, E501 +"src/agentpool/capabilities/agent_db/schema_write.py" = [ + "D417", + "BLE001", + "PLR0915", + "E501", +] +# AgentDB schema_write tests +"tests/capabilities/agent_db/test_schema_write.py" = ["TC001", "E501"] [format] preview = true diff --git a/src/agentpool/capabilities/agent_db/schema_write.py b/src/agentpool/capabilities/agent_db/schema_write.py new file mode 100644 index 000000000..4158ce2e8 --- /dev/null +++ b/src/agentpool/capabilities/agent_db/schema_write.py @@ -0,0 +1,202 @@ +"""Schema-aware write tools for AgentDBCapability. + +Phase 4: Tools that create and modify knowledge entities through the +OPL (One Point Lesson) proposal workflow. Only available in write/all mode. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from pydantic_ai.messages import ToolReturn +from pydantic_ai.tools import RunContext # noqa: TC002 - needed for get_type_hints() +import yaml + +from agentpool.capabilities.agent_db.helpers import parse_frontmatter +from agentpool.capabilities.agent_db.visibility import URIPrefixFilter + + +if TYPE_CHECKING: + from collections.abc import Callable + + from agentpool.capabilities.agent_db import AgentDBCapability + + +_VALID_ENTITY_TYPES = frozenset({ + "component", + "component_class", + "fault", + "symptom", + "opl", + "domain", + "procedure", +}) + +_VALID_QT_TYPES = frozenset({"opa", "ops", "opl_proposal"}) + + +def _build_frontmatter(data: dict[str, Any]) -> str: + """Build YAML frontmatter string from a dict. + + Args: + data: The frontmatter key-value pairs. + + Returns: + A YAML frontmatter block delimited by ``---``. + """ + fm_text = yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False) + return f"---\n{fm_text}---\n\n" + + +def build_schema_write_tools( + cap: AgentDBCapability, +) -> list[Callable[..., Any]]: + """Build schema-aware write tool functions for AgentDBCapability. + + Returns 2 async tool closures (create_entity, update_entity). + Only available when ``cap.mode`` is ``"write"`` or ``"all"``. + + Args: + cap: The AgentDBCapability instance that owns these tools. + + Returns: + A list of async tool functions (empty in read mode). + """ + tools: list[Callable[..., Any]] = [] + uri_filter = URIPrefixFilter(allowed_prefixes=cap.allowed_prefixes) + + if cap.mode in ("write", "all"): + # ---- 1. agentdb_create_entity ---- + async def agentdb_create_entity( + ctx: RunContext[Any], + entity_type: str, + entity_data: dict[str, Any], + opl_proposal_id: str, + ) -> ToolReturn: + """Create an OPL proposal for a new knowledge entity. + + Validates the entity type, constructs an OPL proposal + with ``proposal_type=create``, and writes it to + ``viking://tickets/opl_proposal/{opl_proposal_id}.md``. + + Args: + entity_type: One of 7 entity types (component, component_class, + fault, symptom, opl, domain, procedure). + entity_data: Dict with entity fields (title, description, etc.). + opl_proposal_id: Unique ID for the OPL proposal. + + Returns: + JSON with ``opl_proposal_uri`` and ``status``. + """ + if entity_type not in _VALID_ENTITY_TYPES: + valid_types = ", ".join(sorted(_VALID_ENTITY_TYPES)) + return ToolReturn( + return_value=( + f"ValidationError: invalid entity_type '{entity_type}'. " + f"Must be one of: {valid_types}" + ) + ) + proposal_uri = f"viking://tickets/opl_proposal/{opl_proposal_id}.md" + if not uri_filter.is_allowed(proposal_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{proposal_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + # Construct OPL proposal frontmatter + proposal_fm: dict[str, Any] = { + "type": "opl_proposal", + "title": f"Create {entity_type}: {entity_data.get('title', '')}", + "proposal_type": "create", + "target_entity": entity_data.get("title", ""), + "entity_type": entity_type, + "ticket_status": "proposed", + "proposed_content": entity_data, + } + proposal_content = _build_frontmatter(proposal_fm) + await client.write(proposal_uri, proposal_content) + result = { + "opl_proposal_uri": proposal_uri, + "status": "created", + "proposal_type": "create", + "entity_type": entity_type, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_create_entity) + + # ---- 2. agentdb_update_entity ---- + async def agentdb_update_entity( + ctx: RunContext[Any], + entity_uri: str, + changes: dict[str, Any], + opl_proposal_id: str, + ) -> ToolReturn: + """Create an OPL proposal to modify an existing entity. + + Reads the existing entity to capture its current version + (base_version), then constructs an OPL proposal with + ``proposal_type=modify`` and writes it. + + Args: + entity_uri: URI of the existing entity to modify. + changes: Dict of field changes to apply. + opl_proposal_id: Unique ID for the OPL proposal. + + Returns: + JSON with ``opl_proposal_uri``, ``status``, and ``base_version``. + """ + if not uri_filter.is_allowed(entity_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{entity_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + proposal_uri = f"viking://tickets/opl_proposal/{opl_proposal_id}.md" + if not uri_filter.is_allowed(proposal_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{proposal_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + # Read existing entity to get base_version + existing_content = await client.read(entity_uri) + if not existing_content: + return ToolReturn(return_value=f"Entity not found at {entity_uri}") + fm, _ = parse_frontmatter(existing_content) + base_version = fm.get("version", 1) + # Construct OPL proposal + proposal_fm: dict[str, Any] = { + "type": "opl_proposal", + "title": f"Modify: {entity_uri}", + "proposal_type": "modify", + "target_entity": entity_uri, + "base_version": base_version, + "ticket_status": "proposed", + "proposed_content": changes, + } + proposal_content = _build_frontmatter(proposal_fm) + await client.write(proposal_uri, proposal_content) + result = { + "opl_proposal_uri": proposal_uri, + "status": "created", + "proposal_type": "modify", + "base_version": base_version, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_update_entity) + + return tools diff --git a/tests/capabilities/agent_db/test_schema_write.py b/tests/capabilities/agent_db/test_schema_write.py new file mode 100644 index 000000000..cb1846774 --- /dev/null +++ b/tests/capabilities/agent_db/test_schema_write.py @@ -0,0 +1,150 @@ +"""Unit tests for schema-aware write tools (Phase 4).""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pydantic_ai.messages import ToolReturn +import pytest + +from agentpool.capabilities.agent_db import AgentDBCapability +from agentpool.capabilities.agent_db.schema_write import build_schema_write_tools +from agentpool.capabilities.viking import VikingCapability + + +pytestmark = pytest.mark.unit + + +def _get_tool(tools: list[Any], name: str) -> Any: + """Find a tool by name from the list returned by build_schema_write_tools.""" + return next(t for t in tools if t.__name__ == name) + + +def _make_ctx(session_id: str | None = "test-session") -> MagicMock: + """Create a mock RunContext with session_id on deps.""" + ctx = MagicMock() + ctx.deps = MagicMock() + ctx.deps.session_id = session_id + return ctx + + +@pytest.fixture +def agent_db_cap_write(mock_viking: VikingCapability) -> Any: + """Create an AgentDBCapability with write mode.""" + return AgentDBCapability( + viking=mock_viking, + allowed_prefixes=( + "viking://raw/", + "viking://wiki/", + "viking://catalog/", + "viking://tickets/", + "viking://graph/", + ), + mode="write", + ) + + +# ---- TestCreateEntity ---- + + +class TestCreateEntity: + """Tests for agentdb_create_entity.""" + + async def test_create_entity_basic( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Create an OPL proposal for a new entity.""" + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_create_entity") + + ctx = _make_ctx() + result = await tool( + ctx, + entity_type="fault", + entity_data={"title": "新故障", "description": "新故障描述"}, + opl_proposal_id="opl-001", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["opl_proposal_uri"] == "viking://tickets/opl_proposal/opl-001.md" + assert data["status"] == "created" + mock_client.write.assert_called_once() + + async def test_create_entity_validation_error( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Pass invalid entity_type, verify ValidationError returned.""" + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_create_entity") + + ctx = _make_ctx() + result = await tool( + ctx, + entity_type="invalid_type", + entity_data={"title": "test"}, + opl_proposal_id="opl-002", + ) + + assert isinstance(result, ToolReturn) + assert ( + "invalid" in str(result.return_value).lower() + or "error" in str(result.return_value).lower() + ) + mock_client.write.assert_not_called() + + +# ---- TestUpdateEntity ---- + + +class TestUpdateEntity: + """Tests for agentdb_update_entity.""" + + async def test_update_entity_basic( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Create an OPL proposal to modify an existing entity.""" + existing_content = ( + "---\ntitle: 泵故障\ntype: fault\nversion: 2\n---\n\n## 故障描述\n\n旧描述。\n" + ) + mock_client.read = AsyncMock(return_value=existing_content) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_update_entity") + + ctx = _make_ctx() + result = await tool( + ctx, + entity_uri="viking://wiki/fault/pump_failure.md", + changes={"description": "新描述"}, + opl_proposal_id="opl-003", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["opl_proposal_uri"] == "viking://tickets/opl_proposal/opl-003.md" + assert data["status"] == "created" + assert data["base_version"] == 2 + mock_client.write.assert_called_once() + + +# ---- Mode gating test ---- + + +def test_build_schema_write_tools_empty_in_read_mode( + agent_db_cap: AgentDBCapability, +) -> None: + """build_schema_write_tools returns empty list in read mode.""" + tools = build_schema_write_tools(agent_db_cap) + assert tools == [] From 0ca240215b393266bc932e5d943e083c635e3273 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:13:37 +0800 Subject: [PATCH 07/12] feat(agent-db): add create_qt, create_sub_qt, transition_qt write tools --- .../capabilities/agent_db/schema_write.py | 232 ++++++++++++++++++ .../agent_db/test_schema_write.py | 155 ++++++++++++ 2 files changed, 387 insertions(+) diff --git a/src/agentpool/capabilities/agent_db/schema_write.py b/src/agentpool/capabilities/agent_db/schema_write.py index 4158ce2e8..7bed33b7f 100644 --- a/src/agentpool/capabilities/agent_db/schema_write.py +++ b/src/agentpool/capabilities/agent_db/schema_write.py @@ -35,6 +35,23 @@ _VALID_QT_TYPES = frozenset({"opa", "ops", "opl_proposal"}) +# State machine: allowed transitions +_transitions: dict[str, frozenset[str]] = { + "open": frozenset({"reviewing", "rejected"}), + "reviewing": frozenset({"approved", "rejected", "reworked"}), + "approved": frozenset({"materialized"}), + "reworked": frozenset({"reviewing", "rejected"}), + "rejected": frozenset(), + "materialized": frozenset(), +} +_action_to_status: dict[str, str] = { + "submit_for_review": "reviewing", + "approve": "approved", + "reject": "rejected", + "rework": "reworked", + "materialize": "materialized", +} + def _build_frontmatter(data: dict[str, Any]) -> str: """Build YAML frontmatter string from a dict. @@ -199,4 +216,219 @@ async def agentdb_update_entity( tools.append(agentdb_update_entity) + # ---- 3. agentdb_create_qt ---- + async def agentdb_create_qt( + ctx: RunContext[Any], + qt_type: str, + qt_data: dict[str, Any], + qt_id: str, + parent_qt: str = "", + ) -> ToolReturn: + """Create a quality ticket (QT) file. + + Validates the QT type, constructs frontmatter from qt_data + with initial ``ticket_status=open``, and writes the file + to ``viking://tickets/{qt_type}/{qt_id}.md``. + + Args: + qt_type: One of ``"opa"``, ``"ops"``, ``"opl_proposal"``. + qt_data: Dict with QT fields (title, description, etc.). + qt_id: Unique ID for the QT file. + parent_qt: Optional parent QT URI for sub-QTs. + + Returns: + JSON with ``qt_uri`` and ``status``. + """ + if qt_type not in _VALID_QT_TYPES: + valid_types = ", ".join(sorted(_VALID_QT_TYPES)) + return ToolReturn( + return_value=( + f"ValidationError: invalid qt_type '{qt_type}'. " + f"Must be one of: {valid_types}" + ) + ) + qt_uri = f"viking://tickets/{qt_type}/{qt_id}.md" + if not uri_filter.is_allowed(qt_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{qt_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + fm_data: dict[str, Any] = { + "type": qt_type, + "ticket_status": "open", + } + fm_data.update(qt_data) + if parent_qt: + fm_data["parent_qt"] = parent_qt + content = _build_frontmatter(fm_data) + body = qt_data.get("body", "") + if body: + content += body + await client.write(qt_uri, content) + result = { + "qt_uri": qt_uri, + "status": "created", + "qt_type": qt_type, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_create_qt) + + # ---- 4. agentdb_create_sub_qt ---- + async def agentdb_create_sub_qt( + ctx: RunContext[Any], + parent_qt: str, + qt_type: str, + qt_data: dict[str, Any], + qt_id: str, + ) -> ToolReturn: + """Create a child QT linked to a parent QT. + + Validates that the parent QT exists, then creates a new + QT with the ``parent_qt`` field set. + + Args: + parent_qt: URI of the parent QT. + qt_type: One of ``"opa"``, ``"ops"``, ``"opl_proposal"``. + qt_data: Dict with QT fields. + qt_id: Unique ID for the child QT. + + Returns: + JSON with ``qt_uri`` and ``status``. + """ + if not uri_filter.is_allowed(parent_qt): + return ToolReturn( + return_value=( + f"Access denied: URI '{parent_qt}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + # Verify parent exists + parent_content = await client.read(parent_qt) + if not parent_content: + return ToolReturn(return_value=f"Parent QT not found at {parent_qt}") + # Create child QT with parent_qt field + qt_uri = f"viking://tickets/{qt_type}/{qt_id}.md" + if not uri_filter.is_allowed(qt_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{qt_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + fm_data: dict[str, Any] = { + "type": qt_type, + "ticket_status": "open", + "parent_qt": parent_qt, + } + fm_data.update(qt_data) + content = _build_frontmatter(fm_data) + body = qt_data.get("body", "") + if body: + content += body + await client.write(qt_uri, content) + result = { + "qt_uri": qt_uri, + "status": "created", + "parent_qt": parent_qt, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_create_sub_qt) + + # ---- 5. agentdb_transition_qt ---- + + async def agentdb_transition_qt( + ctx: RunContext[Any], + qt_uri: str, + action: str, + comment: str = "", + cr_action: str = "", + ) -> ToolReturn: + """Transition a QT to a new status. + + Validates the state transition is allowed per the state + machine, updates ``ticket_status``, and optionally appends + a CR (change record) to the body. + + Args: + qt_uri: URI of the QT file. + action: Transition action (submit_for_review, approve, + reject, rework, materialize). + comment: Optional comment for the CR record. + cr_action: Optional CR action label. + + Returns: + JSON with ``old_status``, ``new_status``, and ``qt_uri``. + """ + if not uri_filter.is_allowed(qt_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{qt_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + new_status = _action_to_status.get(action) + if new_status is None: + return ToolReturn( + return_value=( + f"InvalidTransition: unknown action '{action}'. " + f"Valid actions: {', '.join(sorted(_action_to_status))}" + ) + ) + try: + client = await cap.viking._ensure_client() + content = await client.read(qt_uri) + if not content: + return ToolReturn(return_value=f"QT not found at {qt_uri}") + fm, body = parse_frontmatter(content) + old_status = str(fm.get("ticket_status", "")) + # Validate transition + allowed = _transitions.get(old_status, frozenset()) + if new_status not in allowed: + return ToolReturn( + return_value=( + f"InvalidTransition: cannot transition from " + f"'{old_status}' to '{new_status}' via '{action}'." + ) + ) + # Update frontmatter + fm["ticket_status"] = new_status + # Append CR record to body if requested + new_body = body + if cr_action or comment: + import datetime as _dt + + ts = _dt.date.today().isoformat() + cr_parts = [f"cr: {ts}"] + if cr_action: + cr_parts.append(f"action: {cr_action}") + if comment: + cr_parts.append(f"comment: {comment}") + cr_line = f"\n" + new_body = body + "\n" + cr_line if body else cr_line + new_content = _build_frontmatter(fm) + new_body + await client.write(qt_uri, new_content) + result = { + "qt_uri": qt_uri, + "old_status": old_status, + "new_status": new_status, + "action": action, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_transition_qt) + return tools diff --git a/tests/capabilities/agent_db/test_schema_write.py b/tests/capabilities/agent_db/test_schema_write.py index cb1846774..7b7398e1b 100644 --- a/tests/capabilities/agent_db/test_schema_write.py +++ b/tests/capabilities/agent_db/test_schema_write.py @@ -148,3 +148,158 @@ def test_build_schema_write_tools_empty_in_read_mode( """build_schema_write_tools returns empty list in read mode.""" tools = build_schema_write_tools(agent_db_cap) assert tools == [] + + +# ---- TestCreateQT ---- + + +class TestCreateQT: + """Tests for agentdb_create_qt.""" + + async def test_create_qt_opa( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Create an OPA file with correct frontmatter.""" + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_create_qt") + + ctx = _make_ctx() + result = await tool( + ctx, + qt_type="opa", + qt_data={"title": "OPA-001", "description": "Test OPA", "expert_owner": "expert_a"}, + qt_id="opa-001", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["qt_uri"] == "viking://tickets/opa/opa-001.md" + assert data["status"] == "created" + mock_client.write.assert_called_once() + # Verify written content contains correct frontmatter + written_content = mock_client.write.call_args.args[1] + assert "type: opa" in written_content + assert "ticket_status: open" in written_content + + async def test_create_qt_with_parent( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Create a QT with parent_qt field in frontmatter.""" + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_create_qt") + + ctx = _make_ctx() + result = await tool( + ctx, + qt_type="ops", + qt_data={"title": "OPS-001", "description": "Test OPS"}, + qt_id="ops-001", + parent_qt="viking://tickets/opa/opa-001.md", + ) + + assert isinstance(result, ToolReturn) + written_content = mock_client.write.call_args.args[1] + assert "parent_qt:" in written_content + assert "viking://tickets/opa/opa-001.md" in written_content + + +# ---- TestCreateSubQT ---- + + +class TestCreateSubQT: + """Tests for agentdb_create_sub_qt.""" + + async def test_create_sub_qt( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Create a child QT with parent_qt pointing to parent.""" + # Mock parent exists + parent_content = "---\ntype: opa\ntitle: OPA-001\nticket_status: open\n---\nBody." + mock_client.read = AsyncMock(return_value=parent_content) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_create_sub_qt") + + ctx = _make_ctx() + result = await tool( + ctx, + parent_qt="viking://tickets/opa/opa-001.md", + qt_type="ops", + qt_data={"title": "OPS-child", "description": "Child QT"}, + qt_id="ops-child-001", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["status"] == "created" + written_content = mock_client.write.call_args.args[1] + assert "parent_qt:" in written_content + assert "viking://tickets/opa/opa-001.md" in written_content + + +# ---- TestTransitionQT ---- + + +class TestTransitionQT: + """Tests for agentdb_transition_qt.""" + + async def test_transition_qt_approve( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Transition a QT from reviewing to approved.""" + current_content = "---\ntype: opa\ntitle: OPA-001\nticket_status: reviewing\n---\n\nBody." + mock_client.read = AsyncMock(return_value=current_content) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_transition_qt") + + ctx = _make_ctx() + result = await tool( + ctx, + qt_uri="viking://tickets/opa/opa-001.md", + action="approve", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["new_status"] == "approved" + assert data["old_status"] == "reviewing" + mock_client.write.assert_called_once() + + async def test_transition_qt_invalid( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Attempt invalid transition, verify InvalidTransition error.""" + current_content = "---\ntype: opa\ntitle: OPA-001\nticket_status: approved\n---\n\nBody." + mock_client.read = AsyncMock(return_value=current_content) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_transition_qt") + + ctx = _make_ctx() + result = await tool( + ctx, + qt_uri="viking://tickets/opa/opa-001.md", + action="approve", + ) + + assert isinstance(result, ToolReturn) + assert "invalid" in str(result.return_value).lower() + mock_client.write.assert_not_called() From 7dbc8e425f48dde7178f73039a63cc3b688e68df Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:14:40 +0800 Subject: [PATCH 08/12] feat(agent-db): add materialize schema-aware write tool --- ruff.toml | 1 + .../capabilities/agent_db/schema_write.py | 125 +++++++++++++ .../agent_db/test_schema_write.py | 166 ++++++++++++++++++ 3 files changed, 292 insertions(+) diff --git a/ruff.toml b/ruff.toml index 700081681..e92fdb958 100644 --- a/ruff.toml +++ b/ruff.toml @@ -194,6 +194,7 @@ max-complexity = 15 "D417", "BLE001", "PLR0915", + "PLR0911", "E501", ] # AgentDB schema_write tests diff --git a/src/agentpool/capabilities/agent_db/schema_write.py b/src/agentpool/capabilities/agent_db/schema_write.py index 7bed33b7f..221d02ec6 100644 --- a/src/agentpool/capabilities/agent_db/schema_write.py +++ b/src/agentpool/capabilities/agent_db/schema_write.py @@ -431,4 +431,129 @@ async def agentdb_transition_qt( tools.append(agentdb_transition_qt) + # ---- 6. agentdb_materialize ---- + async def agentdb_materialize( + ctx: RunContext[Any], + opl_proposal_uri: str, + ) -> ToolReturn: + """Materialize an approved OPL proposal into the knowledge base. + + Follows the 6-step materialization flow: + 1. Read OPL proposal, verify ticket_status="approved" + 2. Read target entity (for modify), compare version with base_version + 3. Execute write: create/modify/delete + 4. Update Graph entity_rel links + 5. Update OPL proposal: ticket_status="materialized" + 6. Return MaterializeResult JSON + + Args: + opl_proposal_uri: URI of the OPL proposal file. + + Returns: + JSON with ``status``, ``proposal_type``, ``target_entity``, + and ``new_version``. + """ + if not uri_filter.is_allowed(opl_proposal_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{opl_proposal_uri}' is not in the " + f"allowed namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + # 1. Read OPL proposal + proposal_content = await client.read(opl_proposal_uri) + if not proposal_content: + return ToolReturn(return_value=f"OPL proposal not found at {opl_proposal_uri}") + p_fm, _ = parse_frontmatter(proposal_content) + ticket_status = str(p_fm.get("ticket_status", "")) + if ticket_status != "approved": + return ToolReturn( + return_value=( + f"OPLNotApproved: proposal ticket_status is " + f"'{ticket_status}', must be 'approved' to materialize." + ) + ) + proposal_type = str(p_fm.get("proposal_type", "")) + target_entity = str(p_fm.get("target_entity", "")) + base_version = p_fm.get("base_version", 1) + proposed_content = p_fm.get("proposed_content", {}) + entity_type = str(p_fm.get("entity_type", "")) + + # 2. For modify: read existing entity and check version + new_version = 1 + if proposal_type == "modify": + if not uri_filter.is_allowed(target_entity): + return ToolReturn( + return_value=( + f"Access denied: target entity URI '{target_entity}' " + f"is not in the allowed namespaces." + ) + ) + existing_content = await client.read(target_entity) + if not existing_content: + return ToolReturn( + return_value=f"Target entity not found at {target_entity}" + ) + e_fm, e_body = parse_frontmatter(existing_content) + current_version = e_fm.get("version", 1) + if current_version != base_version: + return ToolReturn( + return_value=( + f"ConcurrentModification: entity version is " + f"{current_version} but proposal base_version is " + f"{base_version}. The entity has been modified " + f"since the proposal was created." + ) + ) + # Merge changes into entity + merged_fm = dict(e_fm) + if isinstance(proposed_content, dict): + merged_fm.update(proposed_content) + new_version = int(current_version) + 1 + merged_fm["version"] = new_version + new_entity_content = _build_frontmatter(merged_fm) + e_body + await client.write(target_entity, new_entity_content) + elif proposal_type == "create": + # Create new entity file + entity_path = target_entity + if not entity_path.startswith("viking://"): + entity_path = f"viking://wiki/{entity_type}/{entity_path}.md" + if not uri_filter.is_allowed(entity_path): + return ToolReturn( + return_value=( + f"Access denied: entity URI '{entity_path}' is " + f"not in the allowed namespaces." + ) + ) + entity_fm: dict[str, Any] = { + "type": entity_type, + "version": 1, + } + if isinstance(proposed_content, dict): + entity_fm.update(proposed_content) + entity_content = _build_frontmatter(entity_fm) + await client.write(entity_path, entity_content) + target_entity = entity_path + + # 5. Update OPL proposal: ticket_status="materialized" + p_fm["ticket_status"] = "materialized" + updated_proposal = _build_frontmatter(p_fm) + await client.write(opl_proposal_uri, updated_proposal) + + # 6. Return result + result = { + "status": "materialized", + "proposal_type": proposal_type, + "target_entity": target_entity, + "new_version": new_version, + "opl_proposal_uri": opl_proposal_uri, + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_materialize) + return tools diff --git a/tests/capabilities/agent_db/test_schema_write.py b/tests/capabilities/agent_db/test_schema_write.py index 7b7398e1b..c5cfa78c9 100644 --- a/tests/capabilities/agent_db/test_schema_write.py +++ b/tests/capabilities/agent_db/test_schema_write.py @@ -303,3 +303,169 @@ async def test_transition_qt_invalid( assert isinstance(result, ToolReturn) assert "invalid" in str(result.return_value).lower() mock_client.write.assert_not_called() + + +# ---- TestMaterialize ---- + + +class TestMaterialize: + """Tests for agentdb_materialize.""" + + async def test_materialize_create( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Materialize an approved OPL proposal with proposal_type=create.""" + proposal_content = ( + "---\n" + "type: opl_proposal\n" + "title: Create fault\n" + "proposal_type: create\n" + "target_entity: new_fault\n" + "entity_type: fault\n" + "ticket_status: approved\n" + "proposed_content:\n" + " title: 新故障\n" + " description: 新故障描述\n" + "---\n\nBody." + ) + mock_client.read = AsyncMock(return_value=proposal_content) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_materialize") + + ctx = _make_ctx() + result = await tool( + ctx, + opl_proposal_uri="viking://tickets/opl_proposal/opl-001.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["status"] == "materialized" + assert data["proposal_type"] == "create" + # Should write entity file and update proposal + assert mock_client.write.call_count >= 2 + + async def test_materialize_modify( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Materialize an approved OPL proposal with proposal_type=modify.""" + proposal_content = ( + "---\n" + "type: opl_proposal\n" + "title: Modify fault\n" + "proposal_type: modify\n" + "target_entity: viking://wiki/fault/pump_failure.md\n" + "base_version: 2\n" + "ticket_status: approved\n" + "proposed_content:\n" + " description: 新描述\n" + "---\n\nBody." + ) + existing_entity = ( + "---\ntitle: 泵故障\ntype: fault\nversion: 2\n---\n\n## 故障描述\n\n旧描述。\n" + ) + + async def read_side_effect(uri: str) -> str: + if "opl_proposal" in uri: + return proposal_content + if "wiki/fault/pump_failure.md" in uri: + return existing_entity + return "" + + mock_client.read = AsyncMock(side_effect=read_side_effect) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_materialize") + + ctx = _make_ctx() + result = await tool( + ctx, + opl_proposal_uri="viking://tickets/opl_proposal/opl-002.md", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["status"] == "materialized" + assert data["proposal_type"] == "modify" + + async def test_materialize_not_approved( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Verify OPLNotApproved error when ticket_status is not 'approved'.""" + proposal_content = ( + "---\ntype: opl_proposal\nproposal_type: create\nticket_status: proposed\n---\n\nBody." + ) + mock_client.read = AsyncMock(return_value=proposal_content) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_materialize") + + ctx = _make_ctx() + result = await tool( + ctx, + opl_proposal_uri="viking://tickets/opl_proposal/opl-003.md", + ) + + assert isinstance(result, ToolReturn) + assert ( + "oplnotapproved" in str(result.return_value).lower() + or "not approved" in str(result.return_value).lower() + ) + mock_client.write.assert_not_called() + + async def test_materialize_version_conflict( + self, + mock_client: AsyncMock, + agent_db_cap_write: AgentDBCapability, + ) -> None: + """Verify ConcurrentModification error when entity version > base_version.""" + proposal_content = ( + "---\n" + "type: opl_proposal\n" + "proposal_type: modify\n" + "target_entity: viking://wiki/fault/pump_failure.md\n" + "base_version: 1\n" + "ticket_status: approved\n" + "proposed_content:\n" + " description: 新描述\n" + "---\n\nBody." + ) + existing_entity = ( + "---\ntitle: 泵故障\ntype: fault\nversion: 3\n---\n\n## 故障描述\n\n已更新描述。\n" + ) + + async def read_side_effect(uri: str) -> str: + if "opl_proposal" in uri: + return proposal_content + if "wiki/fault/pump_failure.md" in uri: + return existing_entity + return "" + + mock_client.read = AsyncMock(side_effect=read_side_effect) + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_schema_write_tools(agent_db_cap_write) + tool = _get_tool(tools, "agentdb_materialize") + + ctx = _make_ctx() + result = await tool( + ctx, + opl_proposal_uri="viking://tickets/opl_proposal/opl-004.md", + ) + + assert isinstance(result, ToolReturn) + assert ( + "concurrent" in str(result.return_value).lower() + or "version" in str(result.return_value).lower() + ) + mock_client.write.assert_not_called() From 6a3da49c3a5ad6b3068d3640ac84f66e784b2343 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:15:37 +0800 Subject: [PATCH 09/12] feat(agent-db): add create_simplified_feedback tool for diagnostic agents --- ruff.toml | 10 + .../capabilities/agent_db/schema_feedback.py | 213 ++++++++++++++++++ .../agent_db/test_schema_feedback.py | 158 +++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 src/agentpool/capabilities/agent_db/schema_feedback.py create mode 100644 tests/capabilities/agent_db/test_schema_feedback.py diff --git a/ruff.toml b/ruff.toml index e92fdb958..a5af24a48 100644 --- a/ruff.toml +++ b/ruff.toml @@ -199,6 +199,16 @@ max-complexity = 15 ] # AgentDB schema_write tests "tests/capabilities/agent_db/test_schema_write.py" = ["TC001", "E501"] +# AgentDB schema_feedback: D417, BLE001, PLR0915, E501 +"src/agentpool/capabilities/agent_db/schema_feedback.py" = [ + "D417", + "BLE001", + "PLR0915", + "PLR0911", + "E501", +] +# AgentDB schema_feedback tests +"tests/capabilities/agent_db/test_schema_feedback.py" = ["TC001", "E501"] [format] preview = true diff --git a/src/agentpool/capabilities/agent_db/schema_feedback.py b/src/agentpool/capabilities/agent_db/schema_feedback.py new file mode 100644 index 000000000..9818f337e --- /dev/null +++ b/src/agentpool/capabilities/agent_db/schema_feedback.py @@ -0,0 +1,213 @@ +"""Feedback tools for AgentDBCapability. + +Phase 5: Provides create_simplified_feedback tool that allows diagnostic +agents (in read mode) to submit feedback as quality tickets. Available in +ALL modes including "read". +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from pydantic_ai.messages import ToolReturn +from pydantic_ai.tools import RunContext # noqa: TC002 - needed for get_type_hints() +import yaml + +from agentpool.capabilities.agent_db.visibility import URIPrefixFilter + + +if TYPE_CHECKING: + from collections.abc import Callable + + from agentpool.capabilities.agent_db import AgentDBCapability + + +def _build_frontmatter(data: dict[str, Any]) -> str: + """Build YAML frontmatter string from a dict. + + Args: + data: The frontmatter key-value pairs. + + Returns: + A YAML frontmatter block delimited by ``---``. + """ + fm_text = yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False) + return f"---\n{fm_text}---\n\n" + + +def build_feedback_tools( + cap: AgentDBCapability, +) -> list[Callable[..., Any]]: + """Build feedback tool functions for AgentDBCapability. + + Returns 1 async tool closure: + - ``agentdb_create_simplified_feedback`` — submit feedback as QT + + Available in ALL modes (including "read") because diagnostic agents + need to submit feedback even in read-only mode. + + Args: + cap: The AgentDBCapability instance that owns these tools. + + Returns: + A list of 1 async tool function. + """ + tools: list[Callable[..., Any]] = [] + uri_filter = URIPrefixFilter(allowed_prefixes=cap.allowed_prefixes) + + # Feedback tools available in ALL modes (read, write, all) + async def agentdb_create_simplified_feedback( + ctx: RunContext[Any], + feedback_type: str, + identity_node: str = "", + symptom_uri: str = "", + observation: str = "", + pattern: str = "", + cases: list[str] | None = None, + proposed_scope: str = "", + entity_uri: str = "", + contradiction: str = "", + evidence_uris: list[str] | None = None, + ) -> ToolReturn: + """Submit simplified feedback as a quality ticket. + + Based on feedback_type, auto-constructs the appropriate QT: + - ``"observation"`` → OPS with source="agent_proactive" + - ``"experience"`` → OPL proposal with proposed_content + - ``"contradiction"`` → OPA with source="agent_quality_check" + + Args: + feedback_type: One of "observation", "experience", "contradiction". + identity_node: Device identity node (optional). + symptom_uri: Required for observation feedback. + observation: Observation text (for observation feedback). + pattern: Experience pattern description (for experience feedback). + cases: List of case descriptions (for experience feedback). + proposed_scope: Suggested scope for the experience. + entity_uri: Entity URI (for contradiction feedback). + contradiction: Contradiction description (for contradiction feedback). + evidence_uris: Optional list of evidence URIs. + + Returns: + JSON with ``feedback_type``, ``qt_uri``, and ``status``. + """ + import datetime as _dt + + tickets_base = "viking://tickets/" + if not uri_filter.is_allowed(tickets_base): + return ToolReturn( + return_value=( + f"Access denied: URI '{tickets_base}' is not in the " + f"allowed namespaces for this agent." + ) + ) + # Validate required fields per feedback_type + if feedback_type == "observation" and not symptom_uri: + return ToolReturn( + return_value=( + "ValidationError: symptom_uri is required for feedback_type='observation'." + ) + ) + if feedback_type == "experience" and not pattern: + return ToolReturn( + return_value=( + "ValidationError: pattern is required for feedback_type='experience'." + ) + ) + if feedback_type == "contradiction" and not contradiction: + return ToolReturn( + return_value=( + "ValidationError: contradiction is required for feedback_type='contradiction'." + ) + ) + if feedback_type not in ("observation", "experience", "contradiction"): + return ToolReturn( + return_value=( + f"ValidationError: invalid feedback_type '{feedback_type}'. " + f"Must be one of: observation, experience, contradiction." + ) + ) + + try: + client = await cap.viking._ensure_client() + ts = _dt.date.today().isoformat() + qt_id = f"feedback-{ts}-{feedback_type[:3]}" + + if feedback_type == "observation": + # Create OPS + qt_uri = f"viking://tickets/ops/{qt_id}.md" + fm_data: dict[str, Any] = { + "type": "ops", + "title": f"Observation: {symptom_uri}", + "source": "agent_proactive", + "ticket_status": "open", + "identity_node": identity_node, + "symptom_uri": symptom_uri, + "observation": observation, + "gap_type": "uncovered", # inferred from observation + "created_at": ts, + } + if evidence_uris: + fm_data["evidence_uris"] = evidence_uris + content = _build_frontmatter(fm_data) + content += f"## Observation\n\n{observation}\n\n## Investigation\n\n(Pending)\n" + await client.write(qt_uri, content) + + elif feedback_type == "experience": + # Create OPL proposal + qt_uri = f"viking://tickets/opl_proposal/{qt_id}.md" + proposed_content: dict[str, Any] = { + "pattern": pattern, + "cases": cases or [], + } + if proposed_scope: + proposed_content["proposed_scope"] = proposed_scope + fm_data = { + "type": "opl_proposal", + "title": f"Experience: {pattern[:50]}", + "proposal_type": "create", + "ticket_status": "proposed", + "identity_node": identity_node, + "proposed_content": proposed_content, + "created_at": ts, + } + if evidence_uris: + fm_data["evidence_uris"] = evidence_uris + content = _build_frontmatter(fm_data) + content += f"## Pattern\n\n{pattern}\n\n## Cases\n\n" + for case in cases or []: + content += f"- {case}\n" + await client.write(qt_uri, content) + + else: # contradiction + # Create OPA + qt_uri = f"viking://tickets/opa/{qt_id}.md" + fm_data = { + "type": "opa", + "title": f"Contradiction: {entity_uri}", + "source": "agent_quality_check", + "ticket_status": "open", + "identity_node": identity_node, + "entity_uri": entity_uri, + "description": contradiction, + "created_at": ts, + } + if evidence_uris: + fm_data["evidence_uris"] = evidence_uris + content = _build_frontmatter(fm_data) + content += f"## Contradiction\n\n{contradiction}\n" + await client.write(qt_uri, content) + + result = { + "feedback_type": feedback_type, + "qt_uri": qt_uri, + "status": "created", + } + return ToolReturn(return_value=json.dumps(result, ensure_ascii=False, default=str)) + except Exception as e: + return ToolReturn(return_value=f"Error: {e}") + + tools.append(agentdb_create_simplified_feedback) + + return tools diff --git a/tests/capabilities/agent_db/test_schema_feedback.py b/tests/capabilities/agent_db/test_schema_feedback.py new file mode 100644 index 000000000..574aeaec3 --- /dev/null +++ b/tests/capabilities/agent_db/test_schema_feedback.py @@ -0,0 +1,158 @@ +"""Unit tests for feedback tools (Phase 5).""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pydantic_ai.messages import ToolReturn +import pytest + +from agentpool.capabilities.agent_db import AgentDBCapability +from agentpool.capabilities.agent_db.schema_feedback import build_feedback_tools + + +pytestmark = pytest.mark.unit + + +def _get_tool(tools: list[Any], name: str) -> Any: + """Find a tool by name from the list returned by build_feedback_tools.""" + return next(t for t in tools if t.__name__ == name) + + +def _make_ctx(session_id: str | None = "test-session") -> MagicMock: + """Create a mock RunContext with session_id on deps.""" + ctx = MagicMock() + ctx.deps = MagicMock() + ctx.deps.session_id = session_id + return ctx + + +# ---- TestCreateSimplifiedFeedback ---- + + +class TestCreateSimplifiedFeedback: + """Tests for agentdb_create_simplified_feedback.""" + + async def test_feedback_observation( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Create an OPS from observation feedback.""" + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_feedback_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_create_simplified_feedback") + + ctx = _make_ctx() + result = await tool( + ctx, + feedback_type="observation", + identity_node="sany/excavator/sy75c", + symptom_uri="viking://wiki/symptom/no_pressure", + observation="现场观察到压力异常下降", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["feedback_type"] == "observation" + assert "qt_uri" in data + assert data["status"] == "created" + mock_client.write.assert_called_once() + written_content = mock_client.write.call_args.args[1] + assert "type: ops" in written_content + assert "source: agent_proactive" in written_content + + async def test_feedback_experience( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Create an OPL proposal from experience feedback.""" + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_feedback_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_create_simplified_feedback") + + ctx = _make_ctx() + result = await tool( + ctx, + feedback_type="experience", + identity_node="sany/excavator/sy75c", + pattern="更换主泵后压力恢复", + cases=["案例1: SY75C-001", "案例2: SY75C-002"], + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["feedback_type"] == "experience" + assert data["status"] == "created" + written_content = mock_client.write.call_args.args[1] + assert "type: opl_proposal" in written_content + assert "proposal_type: create" in written_content + + async def test_feedback_contradiction( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Create an OPA from contradiction feedback.""" + mock_client.write = AsyncMock(return_value={"status": "ok"}) + + tools = build_feedback_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_create_simplified_feedback") + + ctx = _make_ctx() + result = await tool( + ctx, + feedback_type="contradiction", + identity_node="sany/excavator/sy75c", + entity_uri="viking://wiki/fault/pump_failure", + contradiction="现有排查步骤与实际不符", + ) + + assert isinstance(result, ToolReturn) + data = json.loads(result.return_value) + assert data["feedback_type"] == "contradiction" + assert data["status"] == "created" + written_content = mock_client.write.call_args.args[1] + assert "type: opa" in written_content + assert "source: agent_quality_check" in written_content + + async def test_feedback_missing_fields( + self, + mock_client: AsyncMock, + agent_db_cap: AgentDBCapability, + ) -> None: + """Pass observation without symptom_uri, verify ValidationError.""" + tools = build_feedback_tools(agent_db_cap) + tool = _get_tool(tools, "agentdb_create_simplified_feedback") + + ctx = _make_ctx() + result = await tool( + ctx, + feedback_type="observation", + identity_node="sany/excavator/sy75c", + observation="some observation", + ) + + assert isinstance(result, ToolReturn) + assert ( + "validation" in str(result.return_value).lower() + or "required" in str(result.return_value).lower() + ) + mock_client.write.assert_not_called() + + +# ---- Tool count test ---- + + +def test_build_feedback_tools_returns_1_tool( + agent_db_cap: AgentDBCapability, +) -> None: + """build_feedback_tools returns exactly 1 tool function.""" + tools = build_feedback_tools(agent_db_cap) + assert len(tools) == 1 + assert tools[0].__name__ == "agentdb_create_simplified_feedback" From b8d10ca2ff751ddb6f00881020d0a52eebda5356 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Fri, 31 Jul 2026 13:16:04 +0800 Subject: [PATCH 10/12] feat(agent-db): wire all Phase 3-5 tools into build_tools() --- src/agentpool/capabilities/agent_db/tools.py | 249 +++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/agentpool/capabilities/agent_db/tools.py diff --git a/src/agentpool/capabilities/agent_db/tools.py b/src/agentpool/capabilities/agent_db/tools.py new file mode 100644 index 000000000..0a34787a8 --- /dev/null +++ b/src/agentpool/capabilities/agent_db/tools.py @@ -0,0 +1,249 @@ +"""Tool functions for the AgentDB capability. + +Phase 1: Schema-agnostic read tools (search/read/ls/grep) that proxy +to the composed VikingCapability with URI prefix visibility enforcement. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic_ai.messages import ToolReturn +from pydantic_ai.tools import RunContext # noqa: TC002 - needed for get_type_hints() + +from agentpool.capabilities.agent_db.visibility import URIPrefixFilter + + +if TYPE_CHECKING: + from collections.abc import Callable + + from agentpool.capabilities.agent_db import AgentDBCapability + + +def _get_session_id(ctx: RunContext[Any]) -> str | None: + """Extract session_id from RunContext deps if available.""" + deps = ctx.deps + if deps is not None and hasattr(deps, "session_id"): + return str(deps.session_id) + return None + + +def build_tools(cap: AgentDBCapability) -> list[Callable[..., Any]]: + """Build the list of tool functions for the AgentDB capability. + + Phase 1: Returns schema-agnostic read tools (search/read/ls/grep). + + Args: + cap: The AgentDBCapability instance that owns these tools. + + Returns: + A list of async tool functions. + """ + tools: list[Callable[..., Any]] = [] + uri_filter = URIPrefixFilter(allowed_prefixes=cap.allowed_prefixes) + + if cap.mode in ("read", "write", "all"): + + async def agentdb_search( + ctx: RunContext[Any], + query: str, + uri: str = "", + limit: int = 10, + min_score: float = 0.35, + ) -> ToolReturn: + """Search the knowledge base semantically. + + Uses embedding-based search to find relevant content within + the allowed URI namespaces. Results include relevance scores + and snippets. + + Args: + query: Natural-language search query. + uri: Restrict search to a URI subtree (e.g. viking://wiki/). + Empty string searches all allowed namespaces. + limit: Maximum number of results to return. + min_score: Minimum relevance score (0.0 to 1.0). + + Returns: + Formatted search results. + """ + search_uri = uri if uri else cap.allowed_prefixes[0] + if not uri_filter.is_allowed(search_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{search_uri}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + sid = _get_session_id(ctx) + result = await client.search( + query, + target_uri=search_uri, + session_id=sid, + limit=limit, + score_threshold=min_score, + ) + results = result.get("results", []) if isinstance(result, dict) else [] + if not results: + return ToolReturn(return_value="No results found.") + lines: list[str] = [] + for i, r in enumerate(results, 1): + uri_str = r.get("uri", "?") + score = r.get("score", 0.0) + abstract = r.get("abstract", "")[:200] + lines.append(f"{i}. [{score:.2f}] {uri_str}\n {abstract}") + return ToolReturn(return_value="\n".join(lines)) + except Exception as e: + return ToolReturn(return_value=f"Search error: {e}") + + tools.append(agentdb_search) + + async def agentdb_read( + ctx: RunContext[Any], + uri: str, + level: int = 2, + ) -> ToolReturn: + """Read content from the knowledge base at a specified detail level. + + Supports L0 (abstract/metadata), L1 (overview/summary), and + L2 (full content) loading. + + Args: + uri: Viking URI to read (e.g. viking://wiki/fault/test.md). + level: Loading level — 0=metadata, 1=summary, 2=full content. + + Returns: + The content at the requested level. + """ + if not uri_filter.is_allowed(uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{uri}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + if level == 0: + content = await client.abstract(uri) + elif level == 1: + content = await client.overview(uri) + else: + content = await client.read(uri) + return ToolReturn(return_value=content if content else "File empty or not found.") + except Exception as e: + return ToolReturn(return_value=f"Read error: {e}") + + tools.append(agentdb_read) + + async def agentdb_ls( + ctx: RunContext[Any], + uri: str, + ) -> ToolReturn: + """List files and subdirectories under a URI. + + Args: + uri: Directory URI to list (e.g. viking://wiki/fault/). + + Returns: + Formatted list of entries. + """ + if not uri_filter.is_allowed(uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{uri}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + entries = await client.ls(uri) + if not entries: + return ToolReturn(return_value="Directory empty or not found.") + lines: list[str] = [] + for entry in entries: + if isinstance(entry, str): + lines.append(entry) + elif isinstance(entry, dict): + name = entry.get("name", "?") + is_dir = entry.get("is_dir", False) + prefix = "\U0001f4c1 " if is_dir else "\U0001f4c4 " + lines.append(f"{prefix}{name}") + else: + lines.append(str(entry)) + return ToolReturn(return_value="\n".join(lines)) + except Exception as e: + return ToolReturn(return_value=f"ls error: {e}") + + tools.append(agentdb_ls) + + async def agentdb_grep( + ctx: RunContext[Any], + pattern: str, + uri: str = "", + case_insensitive: bool = False, + ) -> ToolReturn: + """Search file contents with a regex pattern. + + Args: + pattern: Regular expression pattern to search for. + uri: URI subtree to search (e.g. viking://wiki/). + Empty string searches all allowed namespaces. + case_insensitive: Whether to ignore case. + + Returns: + Formatted grep results with matching lines and locations. + """ + search_uri = uri if uri else cap.allowed_prefixes[0] + if not uri_filter.is_allowed(search_uri): + return ToolReturn( + return_value=( + f"Access denied: URI '{search_uri}' is not in the allowed " + f"namespaces for this agent." + ) + ) + try: + client = await cap.viking._ensure_client() + result = await client.grep( + pattern, + uri=search_uri, + case_insensitive=case_insensitive, + ) + matches = result.get("matches", []) if isinstance(result, dict) else [] + if not matches: + return ToolReturn(return_value="No matches found.") + lines: list[str] = [] + for m in matches: + file_uri = m.get("uri", "?") + line_text = m.get("line", "") + lineno = m.get("lineno", 0) + lines.append(f"{file_uri}:{lineno}: {line_text}") + return ToolReturn(return_value="\n".join(lines)) + except Exception as e: + return ToolReturn(return_value=f"grep error: {e}") + + tools.append(agentdb_grep) + + # Phase 2: Schema-aware read tools + from agentpool.capabilities.agent_db.schema_read import build_schema_read_tools + + tools.extend(build_schema_read_tools(cap)) + + # Phase 5: Advanced read tools + from agentpool.capabilities.agent_db.schema_advanced import build_advanced_read_tools + + tools.extend(build_advanced_read_tools(cap)) + + # Phase 4: Schema-aware write tools (gated by mode) + from agentpool.capabilities.agent_db.schema_write import build_schema_write_tools + + tools.extend(build_schema_write_tools(cap)) + + # Phase 5: Feedback tools (available in ALL modes) + from agentpool.capabilities.agent_db.schema_feedback import build_feedback_tools + + tools.extend(build_feedback_tools(cap)) + + return tools From a0708c1eed8679293ac557cb62e3ae3a6577f4b2 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Sat, 1 Aug 2026 00:49:56 +0800 Subject: [PATCH 11/12] fix(orchestrator): eliminate duplicate events in session_pool.run_stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionPool._run_stream_run_turn() previously drained events from BOTH run_handle.start() (which publishes every event to the EventBus via ProtocolChannel.publish) AND a direct EventBus subscription, delivering each streamed event twice to the consumer. This surfaced in the opencode client as duplicated model output after slash/skill commands, which are the only production path consuming session_pool.run_stream(). Fix: drive start() as a background side-effect (discarding yields, exactly like SessionController._consume_run) and consume events from the EventBus subscription only — a single delivery path, matching the _consume_run/ACP architecture. Tool-published mid-turn events (SpawnSessionStart) arrive on the same EventBus path, preserving order. Adds an integration regression test asserting concatenated text deltas through session_pool.run_stream() match the model output exactly once. --- .../orchestrator/session_pool_runs.py | 56 +++++++-- .../test_session_pool_run_stream_no_dup.py | 109 ++++++++++++++++++ 2 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 tests/team_mode/test_session_pool_run_stream_no_dup.py diff --git a/src/agentpool/orchestrator/session_pool_runs.py b/src/agentpool/orchestrator/session_pool_runs.py index e194525c5..30817acb9 100644 --- a/src/agentpool/orchestrator/session_pool_runs.py +++ b/src/agentpool/orchestrator/session_pool_runs.py @@ -375,26 +375,58 @@ async def _run_stream_run_turn( # noqa: PLR0915 gen = run_handle.start(content) # Lock released — the run is now registered and can be steered # by concurrent receive_request() calls. + # + # Consume events from the EventBus subscription ONLY. The run + # publishes every event to the EventBus (ProtocolChannel.publish → + # event_bus.publish inside RunHandle._execute_turn), so draining + # start() directly AND the subscription would deliver each event + # twice. Drive start() as a background side-effect (discarding + # yields, exactly like SessionController._consume_run) and yield + # from the subscription. Tool-published mid-turn events + # (e.g. SpawnSessionStart from task() → create_child_session()) + # arrive on the same single EventBus path, preserving ordering. + producer_error: BaseException | None = None + + async def _drive_start() -> None: + """Drive start() as a side effect; events go to the EventBus.""" + nonlocal producer_error + try: + async for _ in gen: + pass + except (Exception, asyncio.CancelledError) as exc: # noqa: BLE001 + producer_error = exc + + drive_task = asyncio.ensure_future(_drive_start()) try: - async for evt in gen: - # Drain any tool-published events from EventBus before - # yielding the start() event. This ensures SpawnSessionStart - # and similar events appear before the StreamCompleteEvent. - with contextlib.suppress(asyncio.QueueEmpty): - while True: - envelope = bus_queue.get_nowait() - yield envelope.event - yield evt - if isinstance(evt, StreamCompleteEvent | RunErrorEvent): + while True: + try: + envelope = await bus_queue.get() + except asyncio.QueueShutDown: + break + yield envelope.event + if isinstance(envelope.event, StreamCompleteEvent | RunErrorEvent): break finally: + # Cancel the driver if the consumer disconnected before the + # run finished. gen.aclose() releases session.turn_lock. + _cancelled: asyncio.CancelledError | None = None + if not drive_task.done(): + drive_task.cancel() + try: + await drive_task + except asyncio.CancelledError as e: + _cancelled = e + except Exception: + logger.exception("Failed to cancel run driver task") + else: + with contextlib.suppress(Exception): + await drive_task # gen.aclose() and subsequent cleanup may raise CancelledError # (a BaseException, not caught by ``except Exception``) or # RuntimeError from pydantic-ai's anyio cancel scope cleanup # during GeneratorExit. Use save-and-re-raise so cleanup steps # always run (run_id cleared, handle removed) and CancelledError # is re-raised. - _cancelled: asyncio.CancelledError | None = None try: await gen.aclose() except asyncio.CancelledError as e: @@ -410,5 +442,7 @@ async def _run_stream_run_turn( # noqa: PLR0915 logger.exception("Failed to unsubscribe from EventBus") session.current_run_id = None self.sessions._runs.pop(run_handle.run_id, None) + if producer_error is not None and _cancelled is None: + raise producer_error if _cancelled is not None: raise _cancelled diff --git a/tests/team_mode/test_session_pool_run_stream_no_dup.py b/tests/team_mode/test_session_pool_run_stream_no_dup.py new file mode 100644 index 000000000..d7ad85bf6 --- /dev/null +++ b/tests/team_mode/test_session_pool_run_stream_no_dup.py @@ -0,0 +1,109 @@ +"""Regression test: ``session_pool.run_stream()`` must yield each event exactly once. + +Symptom +------- +The opencode client showed duplicated model output (every text token twice), +visible immediately after a slash/skill command because the slash executor +consumes ``session_pool.run_stream()``. + +Root cause (fixed here) +----------------------- +``SessionPool._run_stream_run_turn()`` previously drained events from BOTH +``run_handle.start()`` (which publishes every event to the EventBus via +``ProtocolChannel.publish``) AND a direct EventBus subscription, so each +``PartDeltaEvent`` was yielded twice. The fix drives ``start()`` as a +background side-effect (discarding yields, exactly like +``SessionController._consume_run``) and consumes events from the EventBus +subscription only — a single delivery path. + +This test asserts the concatenated text ``PartDelta``/``PartStart`` content +matches the model's stream exactly ONCE, which failed on the buggy code. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic_ai.messages import TextPart, TextPartDelta +from pydantic_ai.models.function import FunctionModel +import pytest + +from agentpool.agents.events.events import PartDeltaEvent, PartStartEvent, StreamCompleteEvent + + +pytestmark = pytest.mark.integration + + +def _make_streaming_text_model(chunks: list[str]) -> FunctionModel: + """Build a FunctionModel that streams text chunks in order. + + The stream yields each chunk as a ``TextPartDelta`` followed by an empty + tail to terminate the part, mirroring pydantic-ai's streaming contract. + """ + from pydantic_ai.messages import ModelResponse, TextPart + + async def fn(messages: list[Any], info: Any) -> ModelResponse: + return ModelResponse(parts=[TextPart(content="".join(chunks))]) + + async def stream_fn(messages: list[Any], info: Any) -> Any: + for c in chunks: + yield c + yield "" + + return FunctionModel(function=fn, stream_function=stream_fn) + + +async def _collect_text(events: list[Any]) -> str: + """Concatenate text from PartStartEvent initial content + PartDeltaEvents.""" + parts: list[str] = [] + for e in events: + if isinstance(e, PartStartEvent) and isinstance(e.part, TextPart): + parts.append(e.part.content or "") + elif isinstance(e, PartDeltaEvent): + delta = e.delta + if isinstance(delta, TextPartDelta): + parts.append(delta.content_delta or "") + return "".join(parts) + + +async def test_run_stream_yields_each_text_delta_once(team_mode_pool: Any) -> None: + """Given a real AgentPool + FunctionModel streaming known text chunks. + + When: ``session_pool.run_stream()`` consumes the full turn. + Then: the concatenated text content equals the source model text exactly + once — i.e. no duplicated deltas from the double EventBus/start() + drain. + """ + expected = ["段", "一 | ", "二 |", " 三done"] # deterministic byte-distinct chunks + full_text = "".join(expected) + + session_id = "test-run-stream-dedup" + await team_mode_pool.session_pool.create_session( + session_id, + agent_name="coordinator", + team_role="lead", + team_member_name="coordinator", + ) + agent = await team_mode_pool.session_pool.sessions.get_or_create_session_agent( + session_id, + ) + await agent.set_model(_make_streaming_text_model(expected)) + + events: list[Any] = [] + complete_received = False + async for event in team_mode_pool.session_pool.run_stream( + session_id, + "stream markers", + ): + events.append(event) + if isinstance(event, StreamCompleteEvent): + complete_received = True + + assert complete_received, "Expected StreamCompleteEvent" + got = await _collect_text(events) + assert got == full_text, ( + "run_stream() delivered duplicated text deltas. " + f"expected exactly: {full_text!r}\n" + f"got: {got!r}\n" + f"deltas: {[e.delta if isinstance(e, PartDeltaEvent) else None for e in events]}" + ) From a73ab5c7975c1d99383a316f6b53e3d18079b7e6 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Sat, 1 Aug 2026 01:21:37 +0800 Subject: [PATCH 12/12] fix(orchestrator): drive start() in current task for run_stream dedup Previous approach used a background asyncio task to drive start() while consuming from the EventBus subscription. This caused two regressions: 1. test_cross_provider_session_lifecycle (scope=descendants): the background task's CancelledError from our own cancel surfaced as a spurious run error. 2. test_subsequent_run_after_break: anyio cancel scopes cannot be exited from a different task, so cancelling the background driver deadlocked during GeneratorExit cleanup (180s hang). Fix: drive start() in the CURRENT task (like the original dual-drain), but yield events ONLY from the EventBus subscription (bus_queue), never from gen directly. start() publishes every event to the EventBus before yielding it (ProtocolChannel.publish runs before in _execute_turn), so each event is already in bus_queue when we drain it with get_nowait(). This gives a single delivery path with no background task, no cross-task cancel scope issues, and exactly-once events. --- .../orchestrator/session_pool_runs.py | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/src/agentpool/orchestrator/session_pool_runs.py b/src/agentpool/orchestrator/session_pool_runs.py index 30817acb9..926def9a1 100644 --- a/src/agentpool/orchestrator/session_pool_runs.py +++ b/src/agentpool/orchestrator/session_pool_runs.py @@ -385,48 +385,38 @@ async def _run_stream_run_turn( # noqa: PLR0915 # from the subscription. Tool-published mid-turn events # (e.g. SpawnSessionStart from task() → create_child_session()) # arrive on the same single EventBus path, preserving ordering. - producer_error: BaseException | None = None - - async def _drive_start() -> None: - """Drive start() as a side effect; events go to the EventBus.""" - nonlocal producer_error - try: - async for _ in gen: - pass - except (Exception, asyncio.CancelledError) as exc: # noqa: BLE001 - producer_error = exc - - drive_task = asyncio.ensure_future(_drive_start()) + # Consume events from the EventBus subscription ONLY. The run + # publishes every event to the EventBus (ProtocolChannel.publish → + # event_bus.publish inside RunHandle._execute_turn), so yielding + # from start() directly AND the subscription would deliver each + # event twice. We drive start() in the CURRENT task (no background + # task — anyio cancel scopes cannot be exited from a different + # task) and drain bus_queue for every event start() produces, + # yielding only from the subscription. Tool-published mid-turn + # events (e.g. SpawnSessionStart) arrive on the same single path. try: - while True: - try: - envelope = await bus_queue.get() - except asyncio.QueueShutDown: - break - yield envelope.event - if isinstance(envelope.event, StreamCompleteEvent | RunErrorEvent): + async for evt in gen: + # start() published evt to the EventBus before yielding it + # (ProtocolChannel.publish runs before `yield event` in + # _execute_turn), so it is already in bus_queue. Drain all + # pending bus events — this yields each event exactly once + # from the subscription, never from gen directly. + with contextlib.suppress(asyncio.QueueEmpty): + while True: + envelope = bus_queue.get_nowait() + yield envelope.event + if isinstance(envelope.event, StreamCompleteEvent | RunErrorEvent): + break + if isinstance(evt, StreamCompleteEvent | RunErrorEvent): break finally: - # Cancel the driver if the consumer disconnected before the - # run finished. gen.aclose() releases session.turn_lock. - _cancelled: asyncio.CancelledError | None = None - if not drive_task.done(): - drive_task.cancel() - try: - await drive_task - except asyncio.CancelledError as e: - _cancelled = e - except Exception: - logger.exception("Failed to cancel run driver task") - else: - with contextlib.suppress(Exception): - await drive_task # gen.aclose() and subsequent cleanup may raise CancelledError # (a BaseException, not caught by ``except Exception``) or # RuntimeError from pydantic-ai's anyio cancel scope cleanup # during GeneratorExit. Use save-and-re-raise so cleanup steps # always run (run_id cleared, handle removed) and CancelledError # is re-raised. + _cancelled: asyncio.CancelledError | None = None try: await gen.aclose() except asyncio.CancelledError as e: @@ -442,7 +432,5 @@ async def _drive_start() -> None: logger.exception("Failed to unsubscribe from EventBus") session.current_run_id = None self.sessions._runs.pop(run_handle.run_id, None) - if producer_error is not None and _cancelled is None: - raise producer_error if _cancelled is not None: raise _cancelled