From 1b25e579202d268998463d5e27c594a59a7b68dc Mon Sep 17 00:00:00 2001 From: zuiho <2324465096@qq.com> Date: Wed, 5 Aug 2026 10:20:42 +0800 Subject: [PATCH] Add afd-plugin adapter and knowledge routing --- .env.template | 12 +- adapters/afd_plugin/manifest.yaml | 95 +++++++++++++ adapters/vllm_omni/manifest.yaml | 22 +++ knowledge/SCHEMA.md | 2 +- knowledge/repos/_index.md | 3 +- knowledge/repos/afd-plugin/_index.md | 34 +++++ knowledge/repos/afd-plugin/rules.md | 40 ++++++ src/infermatrix_copilot/adapters/base.py | 33 ++++- src/infermatrix_copilot/thin_mcp_server.py | 155 +++++++++++++++++++-- test/test_adapters.py | 28 +++- test/test_mcp.py | 30 ++++ test/test_routing.py | 10 +- test/test_thin_mcp.py | 36 +++++ 13 files changed, 478 insertions(+), 22 deletions(-) create mode 100644 adapters/afd_plugin/manifest.yaml create mode 100644 knowledge/repos/afd-plugin/_index.md create mode 100644 knowledge/repos/afd-plugin/rules.md create mode 100644 test/test_thin_mcp.py diff --git a/.env.template b/.env.template index 7cda4db..7265954 100644 --- a/.env.template +++ b/.env.template @@ -35,20 +35,26 @@ CLAUDE_CODE_ATTREBUTION_HEADER=0 # Repos: JSON map of repo name -> local path (machine-specific; .env is gitignored, # which is why absolute paths belong HERE and never in a committed file). -REPO_PATHS={"vllm-omni": "/path/to/vllm-omni"} +REPO_PATHS={"vllm-omni": "/path/to/vllm-omni", "afd-plugin": "/path/to/afd-plugin"} DEFAULT_REPO=vllm-omni # alias -> "owner/repo" GitHub identity, used to route pasted GitHub URLs to a # configured repo. Optional when the checkout's origin remote already points at # that owner/repo; without either, URL commands cannot route to the alias # (`doctor` warns about this). -REPO_FULL_NAMES={"vllm-omni": "vllm-project/vllm-omni"} +REPO_FULL_NAMES={"vllm-omni": "vllm-project/vllm-omni", "afd-plugin": "vllm-project/afd-plugin"} + +# MCP defaults to DEFAULT_REPO only. Add afd-plugin explicitly when this server +# should expose its read-only knowledge/review workflows. +MCP_REPO_ALLOWLIST=["vllm-omni", "afd-plugin"] # Fallback used by adapters//manifest.yaml when no .env is in reach; the -# manifest references these as ${VLLM_OMNI_REPO} / ${VLLM_UPSTREAM_REPO} so the +# manifest references these as ${VLLM_OMNI_REPO} / ${VLLM_UPSTREAM_REPO} / +# ${AFD_PLUGIN_REPO} so the # committed manifest stays portable. REPO_PATHS above takes precedence. VLLM_OMNI_REPO=/path/to/vllm-omni VLLM_UPSTREAM_REPO=/path/to/vllm +AFD_PLUGIN_REPO=/path/to/afd-plugin # Push safety: pushes are dry-run unless this is 1 ALLOW_PUSH=0 diff --git a/adapters/afd_plugin/manifest.yaml b/adapters/afd_plugin/manifest.yaml new file mode 100644 index 0000000..4861cbe --- /dev/null +++ b/adapters/afd_plugin/manifest.yaml @@ -0,0 +1,95 @@ +# Human-reviewed adapter for vllm-project/afd-plugin. +name: afd_plugin +status: active +created_by: human +schema_version: 1 +capabilities: [orchestrator.external] + +repo: + path: ${AFD_PLUGIN_REPO} + full_name: vllm-project/afd-plugin + aliases: [afd-plugin, afd_plugin, vllm-project/afd-plugin] + default_branch: main + language: python + +knowledge: + source: vllm-project/afd-plugin + repo_subdir: repos/afd-plugin + briefing_docs: + - repos/afd-plugin/rules.md + - repos/afd-plugin/_index.md + +modules: + compat: + local_paths: [afd_plugin/compat/] + wave: 1 + risk: high + config_validation: + local_paths: [afd_plugin/config.py, afd_plugin/validation.py, afd_plugin/config_utils.py] + wave: 1 + risk: high + connectors_distributed: + local_paths: [afd_plugin/connectors/, afd_plugin/distributed/] + wave: 1 + risk: high + workers_runners: + local_paths: [afd_plugin/v1/worker/, afd_plugin/model_executor/] + wave: 1 + risk: high + native_backends: + local_paths: [csrc/gpu/, csrc/npu/] + wave: 1 + risk: high + tests: + local_paths: [tests/unit/, tests/e2e/] + wave: 2 + +review_routes: +- prefix: afd_plugin/compat/ + owner: compat + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/config.py + owner: config-validation + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/validation.py + owner: config-validation + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/config_utils.py + owner: config-validation + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/connectors/ + owner: connectors-distributed + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/distributed/ + owner: connectors-distributed + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/v1/worker/ + owner: workers-runners + doc: repos/afd-plugin/rules.md +- prefix: afd_plugin/model_executor/ + owner: workers-runners + doc: repos/afd-plugin/rules.md +- prefix: csrc/gpu/ + owner: native-backends + doc: repos/afd-plugin/rules.md +- prefix: csrc/npu/ + owner: native-backends + doc: repos/afd-plugin/rules.md +- prefix: tests/unit/ + owner: tests + doc: repos/afd-plugin/rules.md +- prefix: tests/e2e/ + owner: tests + doc: repos/afd-plugin/rules.md + +validation: + test_manifest_sources: [.github/workflows/] + precommit: true + +ci: + provider: github_actions + +push: + default_remote: origin + protected_branches: [main, master] + allowed: false diff --git a/adapters/vllm_omni/manifest.yaml b/adapters/vllm_omni/manifest.yaml index 38d32ad..ee417d5 100644 --- a/adapters/vllm_omni/manifest.yaml +++ b/adapters/vllm_omni/manifest.yaml @@ -11,6 +11,8 @@ repo: # takes precedence; this is only the fallback when no .env is in reach. Never # hardcode a machine-specific absolute path in this committed file. path: ${VLLM_OMNI_REPO} + full_name: vllm-project/vllm-omni + aliases: [vllm-omni, vllm_omni] default_branch: main rebase_branch: dev/vllm-align language: python @@ -66,6 +68,26 @@ modules: local_paths: [benchmarks/] wave: 2 +review_routes: +- prefix: vllm_omni/config/ + owner: config + doc: repos/vllm-omni/components/configuration/rules.md +- prefix: vllm_omni/core/ + owner: scheduler + doc: repos/vllm-omni/components/scheduler/rules.md +- prefix: vllm_omni/entrypoints/ + owner: serving + doc: repos/vllm-omni/components/serving/rules.md +- prefix: vllm_omni/model_executor/ + owner: model-executor + doc: repos/vllm-omni/components/model-executor/rules.md +- prefix: vllm_omni/models/ + owner: models + doc: repos/vllm-omni/models/_index.md +- prefix: vllm_omni/diffusion/ + owner: diffusion + doc: repos/vllm-omni/components/diffusion/rules.md + validation: test_manifest_sources: [.buildkite/] precommit: true diff --git a/knowledge/SCHEMA.md b/knowledge/SCHEMA.md index d8aa866..996c507 100644 --- a/knowledge/SCHEMA.md +++ b/knowledge/SCHEMA.md @@ -35,7 +35,7 @@ contradictions: [相对路径] # 可选:与本页冲突的页面 新标签必须先加入此表再使用(防止标签蔓延): -- 归属:`general`、`vllm-omni` +- 归属:`general`、`vllm-omni`、`afd-plugin` - 工作主题:`review`、`ci`、`docs`、`git`、`debug`、`benchmark`、`environment`、 `remote`、`agents`、`planning`、`dev`、`rebase` - 代码/模型轴:`components`、`models`、`diffusion`、`model-executor`、`serving`、 diff --git a/knowledge/repos/_index.md b/knowledge/repos/_index.md index 522c197..a892598 100644 --- a/knowledge/repos/_index.md +++ b/knowledge/repos/_index.md @@ -3,7 +3,7 @@ title: "仓库经验入口" created: 2026-07-10 updated: 2026-07-10 type: index -tags: [vllm-omni] +tags: [vllm-omni, afd-plugin] sources: [] --- @@ -24,4 +24,5 @@ sources: [] | 仓库 | 上游地址 | 查看哪里 | |---|---|---| | vLLM-Omni | `vllm-project/vllm-omni` | [vllm-omni](vllm-omni/_index.md) | +| afd-plugin | `vllm-project/afd-plugin` | [afd-plugin](afd-plugin/_index.md) | | Jianghan roleplay data pipeline | 私有项目 | [jianghan-roleplay-data-pipeline](jianghan-roleplay-data-pipeline/_index.md) | diff --git a/knowledge/repos/afd-plugin/_index.md b/knowledge/repos/afd-plugin/_index.md new file mode 100644 index 0000000..c9c588a --- /dev/null +++ b/knowledge/repos/afd-plugin/_index.md @@ -0,0 +1,34 @@ +--- +title: "afd-plugin" +created: 2026-08-05 +updated: 2026-08-05 +type: index +tags: [afd-plugin] +sources: ["vllm-project/afd-plugin:AGENTS.md", "vllm-project/afd-plugin:README.md", "vllm-project/afd-plugin:pyproject.toml", "vllm-project/afd-plugin:.github/workflows/cpu-only-ci.yml"] +--- + +# afd-plugin + +- 上游仓库:`vllm-project/afd-plugin` +- 默认分支:`main` +- 适用范围:AFD 插件的兼容 patch、配置校验、connector/distributed、worker/runner、GPU/NPU native backend、单测和硬件 E2E review +- 仓库自己的 `AGENTS.md`、`CLAUDE.md`、`.agents/skills/run-e2e/SKILL.md`、CI 和脚本保持权威;这里仅保存 InferMatrixCopilot 的最小路由规则 + +## 什么时候查这里 + +- 当前 Git 仓库、GitHub URL 或 PR 目标是 `vllm-project/afd-plugin`。 +- 需要判断 AFD PR 的 changed files 应读哪些仓库专属 review 规则。 + +## 不放什么 + +- 不放 vLLM-Omni 的模型、远端、benchmark 或 rebase 经验。 +- 不复制 AFD 仓库完整 README、AGENTS 或 E2E skill;运行细节以目标仓库当前文件为准。 +- 不记录本机 checkout 路径、硬件账号、模型 cache 或凭据。 + +## 当前入口 + +| 遇到什么 | 查看哪里 | 说明 | +|---|---|---| +| 开始任何 afd-plugin review、issue answer 或仓库初始化 | [硬门禁](rules.md) | AFD 仓库级默认规则 | +| 需要跨仓库 review 基础方法 | [general review](../../general/review/_index.md) | 通用审查入口 | + diff --git a/knowledge/repos/afd-plugin/rules.md b/knowledge/repos/afd-plugin/rules.md new file mode 100644 index 0000000..19d095d --- /dev/null +++ b/knowledge/repos/afd-plugin/rules.md @@ -0,0 +1,40 @@ +--- +title: "afd-plugin 硬门禁" +created: 2026-08-05 +updated: 2026-08-05 +type: rule +tags: [afd-plugin, review, config, distributed, model-executor] +sources: ["vllm-project/afd-plugin:AGENTS.md", "vllm-project/afd-plugin:README.md", "vllm-project/afd-plugin:pyproject.toml", "vllm-project/afd-plugin:.github/workflows/cpu-only-ci.yml", "vllm-project/afd-plugin:.agents/skills/run-e2e/SKILL.md"] +--- + +# afd-plugin 硬门禁 + +只在当前任务明确属于 `vllm-project/afd-plugin` 时应用本页。先按 [afd-plugin 入口](_index.md) 确认仓库身份,再结合 [通用 review 入口](../../general/review/_index.md) 做常规审查。 + +## 1. 仓库身份和权威来源 + +- AFD-1a:review 或 issue 回答前必须确认目标是 canonical `vllm-project/afd-plugin`,不能把 vLLM-Omni 的知识树、模型规则、远端策略或 rebase 假设带入 AFD。 +- AFD-1b:AFD 仓库自己的 `AGENTS.md`、`CLAUDE.md`、`.agents/skills/run-e2e/SKILL.md`、CI workflow 和仓库脚本是权威来源;InferMatrixCopilot 只做只读路由、提示和证据组织。 +- AFD-1c:默认路径保持只读;不能自动发 PR 评论、push、改 protected branch,不能要求 AFD runtime 行为因为本 adapter 初始化而改变。 + +## 2. 兼容范围和插件边界 + +- AFD-2a:兼容结论必须精确写当前声明的 runtime:vLLM `0.19.1`;Ascend NPU baseline 是 vLLM-Ascend `0.19.1rc1`、CANN/NNAL `8.5.1`、torch/torch-npu `2.9.0`。不得推断更宽版本范围。 +- AFD-2b:AFD 通过 `vllm.general_plugins` entry point、`VLLM_PLUGINS`、`additional_config["afd"]`、自动 worker 选择、plugin-owned model wrapper 和窄 compatibility shim 生效;不得建议新增独立 `--afd-config` 之类的公开入口。 +- AFD-2c:`additional_config["afd"]` 之外的 connector 私有字段必须进入 `connector_extra_config`,并由对应 connector parser 校验;未知 AFD 顶层字段应暴露为错误,而不是静默透传。 +- AFD-2d:没有 `additional_config["afd"]` 时必须保持非 AFD 路径隔离;非 AFD 请求、worker、模型和上游 vLLM 行为不能被 AFD patch 或自动选择污染。 + +## 3. Patch、connector 和 worker review + +- AFD-3a:`afd_plugin/compat/` patch 必须 upstream-first:复制目标 tag 的上游函数,保持函数签名和返回类型,使用 `# ### PATCH START` / `# ### PATCH END` 标出 AFD 差异,并写清 patch reason、功能变化和移除或 upstream 计划。 +- AFD-3b:不得用宽泛 `getattr`、`hasattr`、`Any`、`object` 或 `_original_*` delegation 掩盖上游 API 漂移;只有 AGENTS 明确允许的例外才可使用,并必须在 patch 注释说明原因。 +- AFD-3c:`afd_plugin/connectors/`、`afd_plugin/distributed/` 改动必须审 rank 布局、attention/ffn 数量约束、同步/异步语义、端口/host、进程组生命周期、资源 cleanup 和跨 role 同步失败路径。 +- AFD-3d:`afd_plugin/v1/worker/`、`afd_plugin/model_executor/` 改动必须审自动 worker 选择、role-specific runner、Attention/FFN 组件加载边界、scheduler-driven FFN fail-fast、CUDA/ACL graph 模式和 DBO/ubatch 条件。 +- AFD-3e:`csrc/gpu/`、`csrc/npu/` 改动必须区分 CUDA 与 Ascend CANN/ACLNN toolchain;NPU op 构建只能在明确 Ascend 环境或显式 `AFD_BUILD_ASCEND_OPS=1` 下作为证据。 + +## 4. 验证和证据 + +- AFD-4a:CPU-safe 默认检查是 `uv run pytest`、`uv run pytest -q tests/unit -m "not gpu and not vllm_runtime"`、`uv run ruff check .`、`uv run ruff format --check .`;它们不能证明 GPU/NPU runtime、模型精度或性能。 +- AFD-4b:GPU/NPU E2E 必须通过 AFD 仓库 `.agents/skills/run-e2e/SKILL.md` 或仓库测试入口运行,记录 backend、设备数、模型路径、vLLM/vLLM-Ascend/CANN/torch 版本、拓扑、connector、graph/DBO/async 配置和 skipped 原因。 +- AFD-4c:性能、精度或硬件支持 claim 必须给可复现证据:硬件型号、软件版本、driver/toolchain、rank 拓扑、命令、日志或 pytest node id;CPU import/config smoke 不能替代硬件证据。 +- AFD-4d:changed files 没有命中 adapter route 时,必须把 unmatched path 明确留给 host reviewer 继续人工审,不得把它当作已覆盖。 diff --git a/src/infermatrix_copilot/adapters/base.py b/src/infermatrix_copilot/adapters/base.py index eba5160..a6cb211 100644 --- a/src/infermatrix_copilot/adapters/base.py +++ b/src/infermatrix_copilot/adapters/base.py @@ -108,6 +108,25 @@ def repo_path(self) -> str: (`${VAR}` / `~`) instead of hardcoding one machine's absolute layout.""" return expand_path(self.manifest.get("repo", {}).get("path", "")) + @property + def aliases(self) -> set[str]: + """Names that may identify this adapter at the boundary. + + The manifest's canonical `name` remains the primary id, but callers may + receive dashed aliases, GitHub `owner/repo` strings, or explicit aliases + declared in `repo.aliases`. Matching is case-insensitive. + """ + repo = self.manifest.get("repo", {}) or {} + values = {self.name, self.name.replace("_", "-")} + values.update(str(alias) for alias in repo.get("aliases", []) or []) + if repo.get("full_name"): + values.add(str(repo["full_name"])) + return {value.casefold() for value in values if value} + + def matches_name(self, name: str) -> bool: + """Return true when `name` is this adapter's canonical name or alias.""" + return str(name).casefold() in self.aliases + @property def protected_branches(self) -> list[str]: """Branches a push may never target (defaults to `["main"]`) — read by the @@ -129,6 +148,15 @@ def module_for_path(self, path: str) -> str | None: return module return None + @property + def review_routes(self) -> list[dict]: + """Changed-file review routes declared by the manifest. + + Each item is data only, typically `{prefix, doc, owner}`. Runtime code + validates scope before reading the referenced docs. + """ + return list(self.manifest.get("review_routes") or []) + @property def high_risk_modules(self) -> list[str]: """Modules declared `risk: high` — feeds the patch-review trigger.""" @@ -284,7 +312,7 @@ def resolve(self, *, name: str | None = None, repo_path: str | None = None) -> R adapters = self.all() if name: for p in adapters: - if p.name == name: + if p.matches_name(name): return p raise AdapterError(f"no adapter named {name!r}") if repo_path: @@ -301,7 +329,8 @@ def _git(repo: Path, *args: str) -> str: """Run `git ` in `repo` and return its stripped stdout — the read-only git helper the deterministic fingerprint uses.""" out = subprocess.run(["git", *args], cwd=str(repo), capture_output=True, - text=True, encoding="utf-8", errors="replace", timeout=30) + text=True, encoding="utf-8", errors="replace", timeout=30, + check=False) return out.stdout.strip() diff --git a/src/infermatrix_copilot/thin_mcp_server.py b/src/infermatrix_copilot/thin_mcp_server.py index 954ef9d..0ccb584 100644 --- a/src/infermatrix_copilot/thin_mcp_server.py +++ b/src/infermatrix_copilot/thin_mcp_server.py @@ -7,8 +7,8 @@ import signal import sys from pathlib import Path -from typing import Optional +from .adapters import AdapterError, AdapterRegistry, RepoAdapter from .config import Settings from .intent import resolve_repo_alias from .knowledge_docs import KnowledgeDocs, KnowledgeDocsError @@ -40,8 +40,10 @@ def _knowledge_root() -> Path: re.IGNORECASE, ) _PR_NUMBER = re.compile(r"^(?:pr\s*#?\s*)?(\d+)$", re.IGNORECASE) +_ADAPTERS = _ROOT / "adapters" _REPO_ALIASES = { "vllm-project/vllm-omni": "vllm-omni", + "vllm-project/afd-plugin": "afd-plugin", } _DIRECT_OWNER_ROUTES = ( { @@ -209,7 +211,17 @@ def _knowledge_root() -> Path: def _normalize_repo(repo: str) -> str: selected = str(repo or "vllm-omni").strip() - return _REPO_ALIASES.get(selected.casefold(), selected) + alias = _REPO_ALIASES.get(selected.casefold()) + if alias: + return alias + adapter = _adapter_for_repo(selected) + if adapter is not None: + repo_subdir = str( + (adapter.manifest.get("knowledge") or {}).get("repo_subdir") or "") + if repo_subdir.startswith("repos/"): + return repo_subdir.removeprefix("repos/").strip("/") + return adapter.name.replace("_", "-") + return selected.replace("_", "-") def _supported_repos() -> list[str]: @@ -221,6 +233,13 @@ def _supported_repos() -> list[str]: ) +def _adapter_for_repo(repo: str) -> RepoAdapter | None: + try: + return AdapterRegistry(_ADAPTERS).resolve(name=repo) + except AdapterError: + return None + + def _docs(repo: str) -> KnowledgeDocs: repo = _normalize_repo(repo) repo_dir = _KNOWLEDGE / "repos" / repo @@ -232,7 +251,8 @@ def _docs(repo: str) -> KnowledgeDocs: def _guard(fn): try: return fn() - except (KnowledgeDocsError, FileNotFoundError, PolicyError, ValueError) as exc: + except (KnowledgeDocsError, FileNotFoundError, PolicyError, TypeError, + ValueError) as exc: return {"error": str(exc)} @@ -243,6 +263,94 @@ def _knowledge_entry(name: str) -> str: return str(path) +def _adapter_changed_file_routes( + repo: str, + changed_files: list[str], +) -> tuple[list[dict[str, object]], list[str]]: + selected_repo = _normalize_repo(repo) + adapter = _adapter_for_repo(selected_repo) + if adapter is None: + return [], [str(path).replace("\\", "/") for path in changed_files] + routed: list[dict[str, object]] = [] + unmatched: list[str] = [] + for changed_file in changed_files: + path = str(changed_file).replace("\\", "/") + folded = path.casefold() + hits = [] + for route in adapter.review_routes: + prefix = str(route.get("prefix", "")).replace("\\", "/").casefold() + doc = str(route.get("doc", "")) + if prefix and doc and folded.startswith(prefix): + hits.append({ + "owner": route.get("owner") or prefix.rstrip("/"), + "doc": doc, + }) + if not hits: + unmatched.append(path) + continue + for hit in hits: + doc = str(hit["doc"]) + doc_path = _knowledge_path(doc) + routed.append({ + "owner": str(hit["owner"]), + "path": doc_path, + "relative_path": doc, + "reason": f"changed file: {path}", + "changed_file": path, + "repo": selected_repo, + "quick_map": _direct_quick_map(doc_path), + "read_required": False, + }) + return routed, unmatched + + +def _review_knowledge(repo: str, changed_files: list[str]) -> list[dict]: + """Return adapter-backed review knowledge for smoke tests and strict hosts.""" + selected_repo = _normalize_repo(repo) + docs = _docs(selected_repo) + routed, unmatched = _adapter_changed_file_routes(selected_repo, changed_files) + paths = [ + "general/review/_index.md", + f"repos/{selected_repo}/rules.md", + f"repos/{selected_repo}/_index.md", + ] + adapter = _adapter_for_repo(selected_repo) + if adapter is not None: + knowledge = adapter.manifest.get("knowledge") or {} + paths.extend(knowledge.get("briefing_docs") or []) + paths.extend(knowledge.get("briefing_docs_extra") or []) + paths.extend(str(route["relative_path"]) for route in routed) + entries = [] + if changed_files: + lines = ["# Changed-file routing", "", f"repo: {selected_repo}", ""] + if routed: + lines.append("## Routed") + for route in routed: + lines.append( + f"- `{route['changed_file']}` -> {route['owner']} -> " + f"`{route['relative_path']}`") + lines.append("") + if unmatched: + lines.append("## Unmatched") + for path in unmatched: + lines.append(f"- `{path}`") + lines.append("") + lines.append( + "Unmatched changed paths remain reviewer-visible and must not " + "be treated as covered.") + entries.append({ + "path": "changed-file-routing", + "content": "\n".join(lines).strip(), + "next_offset": None, + }) + for path in dict.fromkeys(paths): + try: + entries.append(docs.read(path, limit=24_000)) + except FileNotFoundError: + continue + return entries + + def _knowledge_path(relative_path: str) -> str: path = (_KNOWLEDGE / relative_path).resolve() try: @@ -329,7 +437,7 @@ def _direct_knowledge_routes( *, title: str = "", body: str = "", - changed_files: Optional[list[str]] = None, + changed_files: list[str] | None = None, ) -> dict: """Select bounded Direct knowledge routes from PR intent. @@ -354,6 +462,24 @@ def _direct_knowledge_routes( "scope_validation": [], } if selected_repo != "vllm-omni": + routes, unmatched = _adapter_changed_file_routes( + selected_repo, changed_files) + if routes or unmatched: + return { + "status": "ready" if routes else "description_unrouted", + "selected_by": "adapter_changed_files", + "changed_files_role": "route_and_scope_validation", + "routes": routes[:3], + "scope_validation": [{ + "owner": route["owner"], + "changed_files": [route["changed_file"]], + "selected_from_description": False, + } for route in routes], + "unmatched_changed_files": unmatched, + "unmatched_policy": ( + "Unmatched changed paths remain reviewer-visible and must " + "not be treated as covered."), + } return { "status": "unsupported_exact_router", "selected_by": "title_body", @@ -429,8 +555,8 @@ def _direct_knowledge_routes( def _direct_completion_result( subtraction_signal: str = "", - subtraction: Optional[list[dict[str, str]]] = None, - minimality_proof: Optional[dict[str, str]] = None, + subtraction: list[dict[str, str]] | None = None, + minimality_proof: dict[str, str] | None = None, final_comment_count: int = 1, ) -> dict: """Mechanically gate Direct completion on subtraction classification. @@ -532,7 +658,7 @@ def _strict_review_request( if not target: raise ValueError("target must not be empty") if not isinstance(post, bool): - raise ValueError("post must be a boolean") + raise TypeError("post must be a boolean") match = _PR_URL.search(target) if match: @@ -564,8 +690,8 @@ def _strict_review_request( def build_mcp( - settings: Optional[Settings] = None, - core: Optional[CopilotMCP] = None, + settings: Settings | None = None, + core: CopilotMCP | None = None, ): from mcp.server.fastmcp import FastMCP @@ -607,7 +733,7 @@ def review( review_depth: str = "", title: str = "", body: str = "", - changed_files: Optional[list[str]] = None, + changed_files: list[str] | None = None, repo_path: str = "", ) -> dict: """Begin a Direct or Strict review. @@ -700,8 +826,8 @@ def run() -> dict: @mcp.tool() def validate_direct_review( subtraction_signal: str = "", - subtraction: Optional[list[dict[str, str]]] = None, - minimality_proof: Optional[dict[str, str]] = None, + subtraction: list[dict[str, str]] | None = None, + minimality_proof: dict[str, str] | None = None, final_comment_count: int = 1, ) -> dict: """Validate the Direct completion gate before the only final comment. @@ -764,6 +890,11 @@ def run() -> dict: "query": query, "repo": selected_repo, "matches": matches, + **({"hint": ( + "No literal text match. For review routing, call review. " + "For knowledge edits, call update_knowledge and read its " + "knowledge_entry." + )} if not matches else {}), } return _guard(run) diff --git a/test/test_adapters.py b/test/test_adapters.py index f5e1d50..b0c80a3 100644 --- a/test/test_adapters.py +++ b/test/test_adapters.py @@ -46,6 +46,7 @@ def test_load_and_accessors(adapter_dir, git_repo): def test_registry_resolution(settings, adapter_dir, git_repo): reg = AdapterRegistry(settings.adapters_dir) assert reg.resolve(name="vllm_omni").name == "vllm_omni" + assert reg.resolve(name="vllm-omni").name == "vllm_omni" assert reg.resolve(repo_path=str(git_repo)).name == "vllm_omni" assert reg.resolve(repo_path="/nonexistent") is None with pytest.raises(AdapterError): @@ -67,7 +68,7 @@ def test_high_risk_sections_are_human_only(adapter_dir): def test_bootstrap_draft_stops_and_never_touches_repo(settings, git_repo): before = subprocess.run(["git", "status", "--porcelain"], cwd=git_repo, - capture_output=True, text=True).stdout + capture_output=True, text=True, check=False).stdout fp = fingerprint_repo(git_repo) assert fp["language"] == "python" and fp["default_branch"] == "main" @@ -78,7 +79,7 @@ def test_bootstrap_draft_stops_and_never_touches_repo(settings, git_repo): assert (root / "BOOTSTRAP_REPORT.md").exists() after = subprocess.run(["git", "status", "--porcelain"], cwd=git_repo, - capture_output=True, text=True).stdout + capture_output=True, text=True, check=False).stdout assert before == after # target repo untouched @@ -90,3 +91,26 @@ def test_shipped_adapter_zero_parses(): assert p.manifest["push"]["allowed"] is False assert "main" in p.protected_branches assert p.module_for_path("vllm_omni/core/scheduler.py") == "scheduler" + + +def test_shipped_afd_adapter_parses_and_resolves_alias(): + from infermatrix_copilot.config import _REPO_ROOT + + p = load_adapter(_REPO_ROOT / "adapters" / "afd_plugin") + assert p.name == "afd_plugin" + assert p.manifest["repo"]["full_name"] == "vllm-project/afd-plugin" + assert p.manifest["ci"]["provider"] == "github_actions" + assert p.manifest["push"]["allowed"] is False + assert p.matches_name("afd-plugin") + assert p.matches_name("vllm-project/afd-plugin") + assert p.module_for_path("afd_plugin/compat/patches/config_validation.py") == "compat" + assert p.module_for_path("afd_plugin/connectors/npu/camp2p.py") == \ + "connectors_distributed" + assert p.module_for_path("afd_plugin/v1/worker/attention_worker.py") == \ + "workers_runners" + assert p.module_for_path("csrc/npu/a2e/op_kernel/a2e.cpp") == "native_backends" + assert p.review_routes + + reg = AdapterRegistry(_REPO_ROOT / "adapters") + assert reg.resolve(name="afd-plugin").name == "afd_plugin" + assert reg.resolve(name="vllm-project/afd-plugin").name == "afd_plugin" diff --git a/test/test_mcp.py b/test/test_mcp.py index 4fa64de..ab9c8f6 100644 --- a/test/test_mcp.py +++ b/test/test_mcp.py @@ -329,6 +329,36 @@ def test_mcp_docs_are_repo_scoped(settings, tmp_path): core.close() +def test_mcp_docs_support_afd_plugin_scope(settings, tmp_path): + k = tmp_path / "knowledge" + (k / "general").mkdir(parents=True) + (k / "general" / "guide.md").write_text("GENERAL", encoding="utf-8") + (k / "repos" / "afd-plugin").mkdir(parents=True) + (k / "repos" / "afd-plugin" / "rules.md").write_text( + "AFD ONLY NEEDLE", encoding="utf-8") + (k / "repos" / "vllm-omni").mkdir(parents=True) + (k / "repos" / "vllm-omni" / "rules.md").write_text( + "OMNI SECRET", encoding="utf-8") + adapter = settings.adapters_dir / "afd_plugin" + adapter.mkdir(parents=True) + (adapter / "manifest.yaml").write_text(json.dumps({ + "name": "afd_plugin", + "repo": {"path": str(tmp_path / "repo"), + "aliases": ["afd-plugin"], + "full_name": "vllm-project/afd-plugin"}, + "knowledge": {"repo_subdir": "repos/afd-plugin"}, + }), encoding="utf-8") + settings.knowledge_dir = k + settings.mcp_repo_allowlist = ["afd-plugin"] + core = _core(settings) + assert core.doc_search("AFD NEEDLE", repo="afd-plugin")["matches"] + assert "AFD ONLY" in core.doc_read("repos/afd-plugin/rules.md", + repo="afd-plugin")["content"] + with pytest.raises(ValueError, match="outside the selected"): + core.doc_read("repos/vllm-omni/rules.md", repo="afd-plugin") + core.close() + + def test_exposed_tools_are_read_only_only(settings): pytest.importorskip("mcp") from infermatrix_copilot.mcp_server import build_mcp diff --git a/test/test_routing.py b/test/test_routing.py index 637314b..c9d1d4e 100644 --- a/test/test_routing.py +++ b/test/test_routing.py @@ -9,7 +9,6 @@ from infermatrix_copilot.intent import ( parse_intent, parse_intents, - pre_parse, resolve_repo_alias, validate_spec, ) @@ -58,6 +57,15 @@ def test_url_routes_with_full_identity(rsettings): assert s and s.kind == "pr_review" and s.pr == 5156 and s.repo == "vllm-omni" +def test_afd_url_routes_by_configured_canonical_identity(rsettings): + rsettings.repo_paths["afd-plugin"] = "/nonexistent/afd-plugin" + rsettings.repo_full_names["afd-plugin"] = "vllm-project/afd-plugin" + r = parse_intent("review https://github.com/vllm-project/afd-plugin/pull/196", + llm=NeverLLM(), settings=rsettings) + s = r.spec + assert s and s.kind == "pr_review" and s.pr == 196 and s.repo == "afd-plugin" + + def test_url_owner_mismatch_is_rejected_not_defaulted(rsettings): r = parse_intent("review https://github.com/evil/vllm-omni/pull/5156", llm=NeverLLM(), settings=rsettings) diff --git a/test/test_thin_mcp.py b/test/test_thin_mcp.py new file mode 100644 index 0000000..c8f998b --- /dev/null +++ b/test/test_thin_mcp.py @@ -0,0 +1,36 @@ +from infermatrix_copilot.thin_mcp_server import _review_knowledge + + +def _entry(entries, path): + return next(item for item in entries if item["path"] == path) + + +def test_thin_mcp_routes_afd_changed_files_and_keeps_unmatched_visible(): + entries = _review_knowledge("afd-plugin", [ + "afd_plugin/compat/patches/config_validation.py", + "afd_plugin/connectors/npu/camp2p.py", + "afd_plugin/v1/worker/attention_worker.py", + "csrc/npu/a2e/op_kernel/a2e.cpp", + "tests/e2e/features/test_serving_npu.py", + "docs/design/module/index.md", + ]) + + routing = _entry(entries, "changed-file-routing")["content"] + assert "compat" in routing + assert "connectors-distributed" in routing + assert "workers-runners" in routing + assert "native-backends" in routing + assert "tests" in routing + assert "docs/design/module/index.md" in routing + assert "must not be treated as covered" in routing + assert any(item["path"] == "repos/afd-plugin/rules.md" for item in entries) + + +def test_thin_mcp_routes_vllm_omni_from_manifest_not_core_branch(): + entries = _review_knowledge("vllm-omni", [ + "vllm_omni/config/schema.py", + "vllm_omni/model_executor/runner.py", + ]) + paths = {item["path"] for item in entries} + assert "repos/vllm-omni/components/configuration/rules.md" in paths + assert "repos/vllm-omni/components/model-executor/rules.md" in paths