eduevidence 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- package/visualization/eduevidence-report/themes/presentation.css +140 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""agent_mcp.py — Conditional Agent MCP integration with Mandatory Confirmation Gate.
|
|
3
|
+
|
|
4
|
+
Agent MCP is **directly installed, never migrated**. EduEvidence only does:
|
|
5
|
+
|
|
6
|
+
scan -> recommend -> ask the user -> execute after explicit confirmation
|
|
7
|
+
|
|
8
|
+
Gate principle (Phase 5-9): Scan first. Recommend second. Ask the user.
|
|
9
|
+
Execute only after explicit confirmation.
|
|
10
|
+
|
|
11
|
+
No hardcoded model names: ROLE_REQUIREMENTS describes role *capability*
|
|
12
|
+
requirements only. Concrete CLI/model choices come from the user-approved
|
|
13
|
+
mapping stored in agent_mcp_approval.json — anything unverifiable is reported
|
|
14
|
+
as `unknown` and never guessed.
|
|
15
|
+
|
|
16
|
+
When agent-mcp is installed and its daemon is reachable, advanced features
|
|
17
|
+
become available:
|
|
18
|
+
- multi-CLI dispatch (fast / strong / independent model routing)
|
|
19
|
+
- Cross-Model Review (an independent model verifies the draft verdict)
|
|
20
|
+
- Memory Bank (memory_store / memory_recall for long-running research)
|
|
21
|
+
|
|
22
|
+
When unavailable, everything degrades to Platform Native Mode (single-agent
|
|
23
|
+
serial execution of the 8-role protocol) with no behavioral break.
|
|
24
|
+
|
|
25
|
+
Spawning is only reachable through safe_spawn(): it verifies that an explicit
|
|
26
|
+
user approval exists, that the approval hash is intact, that the requested
|
|
27
|
+
CLI / model / role are exactly the approved ones, and only then builds the
|
|
28
|
+
spawn payload. Any failure returns AGENT_MCP_APPROVAL_REQUIRED — business
|
|
29
|
+
code MUST NOT call spawn directly.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import shutil
|
|
38
|
+
import socket
|
|
39
|
+
import subprocess
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
AGENT_MCP_ENV_FILE = os.environ.get("AGENT_MCP_ENV_FILE", "~/.eduevidence/env")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _env_file_values(path: str | Path | None = None) -> dict[str, str]:
|
|
48
|
+
"""Parse KEY=VALUE lines from ~/.eduevidence/env (best-effort).
|
|
49
|
+
|
|
50
|
+
install.sh writes AGENT_MCP_INSTALLED=1 there after installation; it is a
|
|
51
|
+
*fallback* env source — real environment variables always win
|
|
52
|
+
(see _effective_env).
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
text = Path(os.path.expanduser(path or AGENT_MCP_ENV_FILE)).read_text(encoding="utf-8")
|
|
56
|
+
except OSError:
|
|
57
|
+
return {}
|
|
58
|
+
values: dict[str, str] = {}
|
|
59
|
+
for line in text.splitlines():
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
62
|
+
continue
|
|
63
|
+
key, _, value = line.partition("=")
|
|
64
|
+
values[key.strip()] = value.strip().strip("\"'")
|
|
65
|
+
return values
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _effective_env(file_values: dict[str, str], key: str, default: str = "") -> str:
|
|
69
|
+
"""Resolve one setting: real env var > ~/.eduevidence/env > default."""
|
|
70
|
+
return os.environ.get(key) or file_values.get(key) or default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_ENV_FILE_VALUES = _env_file_values()
|
|
74
|
+
AGENT_MCP_PORT = int(_effective_env(_ENV_FILE_VALUES, "AGENT_MCP_PORT", "8765"))
|
|
75
|
+
AGENT_MCP_HOME = _effective_env(_ENV_FILE_VALUES, "AGENT_MCP_HOME",
|
|
76
|
+
os.environ.get("CODEX_HOME", "~/.codex"))
|
|
77
|
+
AGENT_MCP_INSTALLED = _effective_env(_ENV_FILE_VALUES, "AGENT_MCP_INSTALLED",
|
|
78
|
+
"").lower() in ("1", "true", "yes")
|
|
79
|
+
|
|
80
|
+
# Failure states per 总体实施计划 §54 + Phase 8 Approval Gate.
|
|
81
|
+
AGENT_MCP_UNAVAILABLE = "AGENT_MCP_UNAVAILABLE"
|
|
82
|
+
AGENT_MCP_APPROVAL_REQUIRED = "AGENT_MCP_APPROVAL_REQUIRED"
|
|
83
|
+
|
|
84
|
+
# Role capability requirements shipped with this repo (skill/agents/*.md).
|
|
85
|
+
# Capabilities only — NO model names, NO CLI names. `None` means the role has
|
|
86
|
+
# no requirement for that dimension (unknown capability is acceptable).
|
|
87
|
+
# skeptic requires a *different model family* than the primary analysis;
|
|
88
|
+
# that can never be satisfied by spawning the same model in another session.
|
|
89
|
+
ROLE_REQUIREMENTS: dict[str, dict[str, Any]] = {
|
|
90
|
+
"education-planner": {
|
|
91
|
+
"reasoning": "high", "speed": None, "cost": None,
|
|
92
|
+
"structured_output": None, "context": None, "tool_use": None,
|
|
93
|
+
"multimodal": None,
|
|
94
|
+
},
|
|
95
|
+
"evidence-retriever": {
|
|
96
|
+
"reasoning": None, "speed": "high", "cost": "low",
|
|
97
|
+
"structured_output": None, "context": None, "tool_use": "strong",
|
|
98
|
+
"multimodal": None,
|
|
99
|
+
},
|
|
100
|
+
"evidence-analyst": {
|
|
101
|
+
"reasoning": "medium+", "speed": None, "cost": None,
|
|
102
|
+
"structured_output": "strong", "context": None, "tool_use": None,
|
|
103
|
+
"multimodal": None,
|
|
104
|
+
},
|
|
105
|
+
"skeptic": {
|
|
106
|
+
"reasoning": "high", "speed": None, "cost": None,
|
|
107
|
+
"structured_output": None, "context": None, "tool_use": None,
|
|
108
|
+
"multimodal": None,
|
|
109
|
+
"independence": "different-model-family",
|
|
110
|
+
},
|
|
111
|
+
"method-reviewer": {
|
|
112
|
+
"reasoning": "high", "speed": None, "cost": None,
|
|
113
|
+
"structured_output": None, "context": "high", "tool_use": None,
|
|
114
|
+
"multimodal": None,
|
|
115
|
+
},
|
|
116
|
+
"evidence-judge": {
|
|
117
|
+
"reasoning": "highest", "speed": None, "cost": None,
|
|
118
|
+
"structured_output": "strong", "context": None, "tool_use": None,
|
|
119
|
+
"multimodal": None,
|
|
120
|
+
},
|
|
121
|
+
"intervention-designer": {
|
|
122
|
+
"reasoning": "high", "speed": None, "cost": None,
|
|
123
|
+
"structured_output": None, "context": None, "tool_use": None,
|
|
124
|
+
"multimodal": None,
|
|
125
|
+
},
|
|
126
|
+
"evaluation-designer": {
|
|
127
|
+
"reasoning": "high", "speed": None, "cost": None,
|
|
128
|
+
"structured_output": None, "context": None, "tool_use": None,
|
|
129
|
+
"multimodal": None,
|
|
130
|
+
"quantitative": "preferred",
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Human-readable one-line task per role (for the user-facing recommendation
|
|
135
|
+
# table). Display metadata only — not a routing decision.
|
|
136
|
+
ROLE_TASKS: dict[str, str] = {
|
|
137
|
+
"education-planner": "Framing:把教学问题转成 EducationResearchFrame",
|
|
138
|
+
"evidence-retriever": "检索支持与反方证据,去重初筛",
|
|
139
|
+
"evidence-analyst": "证据结构化抽取为 Evidence Objects",
|
|
140
|
+
"skeptic": "独立反证:9 项检查,找 null/negative/contradictory 证据",
|
|
141
|
+
"method-reviewer": "方法学审计(样本/测量/结论范围)",
|
|
142
|
+
"evidence-judge": "Tribunal 裁决四态结论",
|
|
143
|
+
"intervention-designer": "干预方案设计(适用性/成本/风险)",
|
|
144
|
+
"evaluation-designer": "评估设计(对照组/指标/定量分析)",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# CLI discovery commands (best-effort; CLIs without a working discovery
|
|
148
|
+
# command report models: [] — never guessed).
|
|
149
|
+
_CLI_MODEL_COMMANDS: dict[str, tuple[list[str], str]] = {
|
|
150
|
+
"opencode": (["opencode", "models"], "slash_lines"),
|
|
151
|
+
"omp": (["omp", "models"], "omp_table"),
|
|
152
|
+
"codex": (["codex", "models"], "slash_lines"),
|
|
153
|
+
"grok": (["grok", "models"], "slash_lines"),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Model-family heuristics for the skeptic independence check (family names
|
|
157
|
+
# are coarse identifiers, not capability claims).
|
|
158
|
+
_FAMILY_RULES: tuple[tuple[str, str], ...] = (
|
|
159
|
+
("claude", "anthropic"),
|
|
160
|
+
("fable", "anthropic"),
|
|
161
|
+
("gemini", "google"),
|
|
162
|
+
("gpt-", "openai"),
|
|
163
|
+
("luna", "openai"),
|
|
164
|
+
("deepseek", "deepseek"),
|
|
165
|
+
("dsh", "deepseek"),
|
|
166
|
+
("ds-flash", "deepseek"),
|
|
167
|
+
("glm", "zhipu"),
|
|
168
|
+
("kimi", "moonshot"),
|
|
169
|
+
("moonshot", "moonshot"),
|
|
170
|
+
("grok", "xai"),
|
|
171
|
+
("qwen", "alibaba"),
|
|
172
|
+
("llama", "meta"),
|
|
173
|
+
("mimo", "minimax"),
|
|
174
|
+
("nemotron", "nvidia"),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
178
|
+
|
|
179
|
+
_REASONING_RANK = {"highest": 5, "high": 4, "medium+": 3, "medium": 2, "low": 1}
|
|
180
|
+
_COST_RANK = {"low": 3, "medium": 2, "high": 1}
|
|
181
|
+
_QUAL_RANK = {"strong": 3, "medium": 2, "weak": 1}
|
|
182
|
+
_CONTEXT_TIEBREAK = {"high": 3, "medium": 2, "low": 1, "unknown": 0}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AgentMCPUnavailable(RuntimeError):
|
|
186
|
+
"""Raised when agent-mcp is not installed or its daemon is not reachable."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------------------------
|
|
190
|
+
# detect / require — availability probe (tri-state, OPEN-5)
|
|
191
|
+
# --------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _daemon_reachable(port: int | None = None) -> bool:
|
|
194
|
+
"""Best-effort probe of the agent-mcp daemon on 127.0.0.1:<port>."""
|
|
195
|
+
try:
|
|
196
|
+
with socket.create_connection(("127.0.0.1", AGENT_MCP_PORT if port is None else port),
|
|
197
|
+
timeout=0.5):
|
|
198
|
+
return True
|
|
199
|
+
except OSError:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def detect_agent_mcp() -> dict[str, Any]:
|
|
204
|
+
"""Probe availability: env marker + daemon health endpoint.
|
|
205
|
+
|
|
206
|
+
Tri-state detection (OPEN-5 — a running daemon must not look like a hard
|
|
207
|
+
failure when the env marker is missing):
|
|
208
|
+
|
|
209
|
+
- "available": env declares AGENT_MCP_INSTALLED AND the daemon is reachable
|
|
210
|
+
- "daemon_reachable_undeclared": daemon reachable but env not declared —
|
|
211
|
+
the host is running agent-mcp; set AGENT_MCP_INSTALLED=1 (via
|
|
212
|
+
~/.eduevidence/env, shell profile, or host MCP injection) to enable it
|
|
213
|
+
- "unavailable": not installed / fully unavailable (env declares but
|
|
214
|
+
daemon down is also reported here, with the daemon reason)
|
|
215
|
+
|
|
216
|
+
Backward compatible: available / mode / port / home / reasons /
|
|
217
|
+
enhanced_features keep their prior meaning; state / reason / hint are
|
|
218
|
+
additive. Returns an availability report (never raises).
|
|
219
|
+
"""
|
|
220
|
+
declared = AGENT_MCP_INSTALLED
|
|
221
|
+
daemon_reachable = _daemon_reachable()
|
|
222
|
+
reasons: list[str] = []
|
|
223
|
+
|
|
224
|
+
if not declared:
|
|
225
|
+
reasons.append("AGENT_MCP_INSTALLED env not set")
|
|
226
|
+
if not daemon_reachable:
|
|
227
|
+
reasons.append(f"daemon not reachable on 127.0.0.1:{AGENT_MCP_PORT}")
|
|
228
|
+
|
|
229
|
+
if declared and daemon_reachable:
|
|
230
|
+
state = "available"
|
|
231
|
+
elif daemon_reachable:
|
|
232
|
+
state = "daemon_reachable_undeclared"
|
|
233
|
+
else:
|
|
234
|
+
state = "unavailable"
|
|
235
|
+
|
|
236
|
+
available = state == "available"
|
|
237
|
+
if state == "daemon_reachable_undeclared":
|
|
238
|
+
reason = (f"daemon reachable on 127.0.0.1:{AGENT_MCP_PORT} but "
|
|
239
|
+
"AGENT_MCP_INSTALLED is not declared")
|
|
240
|
+
hint = ("Agent MCP daemon 可达但未声明安装:设置 AGENT_MCP_INSTALLED=1 "
|
|
241
|
+
"(写入 ~/.eduevidence/env、shell profile,或由宿主 MCP 层注入)"
|
|
242
|
+
"即可启用 agent_mcp_enhanced")
|
|
243
|
+
elif available:
|
|
244
|
+
reason = "agent-mcp installed and daemon reachable"
|
|
245
|
+
hint = ""
|
|
246
|
+
else:
|
|
247
|
+
reason = "agent-mcp not available (see reasons)"
|
|
248
|
+
hint = ""
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"available": available,
|
|
252
|
+
"state": state,
|
|
253
|
+
"mode": "agent_mcp_enhanced" if available else "platform_native",
|
|
254
|
+
"port": AGENT_MCP_PORT,
|
|
255
|
+
"home": os.path.expanduser(AGENT_MCP_HOME),
|
|
256
|
+
"reason": reason,
|
|
257
|
+
"reasons": reasons,
|
|
258
|
+
"hint": hint,
|
|
259
|
+
"enhanced_features": {
|
|
260
|
+
"multi_cli_dispatch": available,
|
|
261
|
+
"cross_model_review": available,
|
|
262
|
+
"memory_bank": available,
|
|
263
|
+
} if available else {
|
|
264
|
+
"multi_cli_dispatch": False,
|
|
265
|
+
"cross_model_review": False,
|
|
266
|
+
"memory_bank": False,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def require_agent_mcp() -> dict[str, Any]:
|
|
272
|
+
"""Return the availability report, raising if agent-mcp is unavailable."""
|
|
273
|
+
report = detect_agent_mcp()
|
|
274
|
+
if not report["available"]:
|
|
275
|
+
raise AgentMCPUnavailable(AGENT_MCP_UNAVAILABLE)
|
|
276
|
+
return report
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --------------------------------------------------------------------------
|
|
280
|
+
# Phase 5.3 — Model Inventory (scan only the user-approved CLI set)
|
|
281
|
+
# --------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def _run_cli_cmd(argv: list[str], timeout: int = 20) -> str:
|
|
284
|
+
"""Run a CLI discovery command, returning combined stdout+stderr."""
|
|
285
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
286
|
+
return (proc.stdout or "") + (proc.stderr or "")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _parse_slash_lines(text: str) -> list[str]:
|
|
290
|
+
"""Parse `opencode models`-style output: one 'provider/model' per line."""
|
|
291
|
+
out: list[str] = []
|
|
292
|
+
for line in text.splitlines():
|
|
293
|
+
line = _ANSI_RE.sub("", line).strip()
|
|
294
|
+
if "/" in line and not line.startswith(("┌", "└", "│", "├", "─")):
|
|
295
|
+
out.append(line)
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_omp_table(text: str) -> list[dict[str, str]]:
|
|
300
|
+
"""Parse `omp models` box table into {model, context, images} records."""
|
|
301
|
+
provider: str | None = None
|
|
302
|
+
records: list[dict[str, str]] = []
|
|
303
|
+
for raw in text.splitlines():
|
|
304
|
+
line = _ANSI_RE.sub("", raw).strip()
|
|
305
|
+
m = re.match(r"^(\S+)\s+\(\d+\)$", line)
|
|
306
|
+
if m:
|
|
307
|
+
provider = m.group(1)
|
|
308
|
+
continue
|
|
309
|
+
if "│" not in line:
|
|
310
|
+
continue
|
|
311
|
+
cells = [c.strip() for c in line.split("│")]
|
|
312
|
+
cells = [c for c in cells if c and not c.startswith(("─", "┌", "└", "├"))]
|
|
313
|
+
if len(cells) < 2 or cells[0] in ("model", "name"):
|
|
314
|
+
continue
|
|
315
|
+
records.append({
|
|
316
|
+
"model": f"{provider}/{cells[0]}" if provider else cells[0],
|
|
317
|
+
"context": cells[1] if len(cells) > 1 else "-",
|
|
318
|
+
"images": cells[-1] if len(cells) > 3 else "-",
|
|
319
|
+
})
|
|
320
|
+
return records
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def scan_cli_models(cli: str, *, timeout: int = 20) -> dict[str, Any]:
|
|
324
|
+
"""Scan one CLI for models reachable via its own discovery command.
|
|
325
|
+
|
|
326
|
+
Returns {"available": bool, "models": [str], "model_details": {model: {...}}}.
|
|
327
|
+
Unverifiable CLIs report models: [] — never guesses.
|
|
328
|
+
"""
|
|
329
|
+
entry: dict[str, Any] = {
|
|
330
|
+
"available": shutil.which(cli) is not None,
|
|
331
|
+
"models": [],
|
|
332
|
+
"model_details": {},
|
|
333
|
+
}
|
|
334
|
+
command = _CLI_MODEL_COMMANDS.get(cli)
|
|
335
|
+
if not entry["available"] or command is None:
|
|
336
|
+
return entry
|
|
337
|
+
argv, kind = command
|
|
338
|
+
try:
|
|
339
|
+
text = _run_cli_cmd(argv, timeout=timeout)
|
|
340
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
341
|
+
return entry
|
|
342
|
+
if kind == "omp_table":
|
|
343
|
+
for rec in _parse_omp_table(text):
|
|
344
|
+
entry["models"].append(rec["model"])
|
|
345
|
+
entry["model_details"][rec["model"]] = {
|
|
346
|
+
"context": rec["context"], "images": rec["images"]}
|
|
347
|
+
else:
|
|
348
|
+
entry["models"] = _parse_slash_lines(text)
|
|
349
|
+
return entry
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def scan_available_models(allowed_clis: list[str], *, timeout: int = 20) -> dict[str, Any]:
|
|
353
|
+
"""Scan the user-approved CLI set (never the whole machine)."""
|
|
354
|
+
return {cli: scan_cli_models(cli, timeout=timeout) for cli in allowed_clis}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def write_model_inventory(allowed_clis: list[str], runs_dir: str = "runs",
|
|
358
|
+
run_id: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
359
|
+
"""Write runs/<run_id>/model_inventory.json (Phase 5.3).
|
|
360
|
+
|
|
361
|
+
Shape: {"scanned_at": iso8601, "clis": {cli: {"available": bool,
|
|
362
|
+
"models": [str], "model_details": {...}}}}.
|
|
363
|
+
"""
|
|
364
|
+
run_id = run_id or os.environ.get("RUN_ID") or datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
365
|
+
out_dir = Path(runs_dir) / run_id
|
|
366
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
inventory: dict[str, Any] = {
|
|
368
|
+
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
|
369
|
+
"clis": scan_available_models(allowed_clis),
|
|
370
|
+
}
|
|
371
|
+
path = out_dir / "model_inventory.json"
|
|
372
|
+
path.write_text(json.dumps(inventory, ensure_ascii=False, indent=2) + "\n",
|
|
373
|
+
encoding="utf-8")
|
|
374
|
+
return path, inventory
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# --------------------------------------------------------------------------
|
|
378
|
+
# Phase 5.4 — Model Capability Profile (verifiable facts only; else unknown)
|
|
379
|
+
# --------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
def _family(model: str) -> str:
|
|
382
|
+
"""Coarse model family from the model name (for independence checks)."""
|
|
383
|
+
name = model.split("/")[-1].lower()
|
|
384
|
+
for token, family in _FAMILY_RULES:
|
|
385
|
+
if token in name:
|
|
386
|
+
return family
|
|
387
|
+
return "unknown"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _context_from_label(context: str) -> str | None:
|
|
391
|
+
"""'1M'/'203K'/'-' -> 'high'|'medium'|'low'|None."""
|
|
392
|
+
m = re.match(r"([\d.]+)([KM])", context)
|
|
393
|
+
if not m:
|
|
394
|
+
return None
|
|
395
|
+
tokens = int(float(m.group(1)) * (1000 if m.group(2) == "K" else 1_000_000))
|
|
396
|
+
if tokens >= 200_000:
|
|
397
|
+
return "high"
|
|
398
|
+
if tokens >= 32_000:
|
|
399
|
+
return "medium"
|
|
400
|
+
return "low"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def capability_profile(model: str, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
404
|
+
"""Verified capability profile for one model.
|
|
405
|
+
|
|
406
|
+
Every dimension is 'unknown' unless a discovery command provided
|
|
407
|
+
verifiable facts (context window, image input). reasoning/speed/cost/
|
|
408
|
+
structured_output/tool_use are NOT guessed.
|
|
409
|
+
"""
|
|
410
|
+
detail: dict[str, Any] = {}
|
|
411
|
+
if inventory:
|
|
412
|
+
for cli_entry in inventory.get("clis", {}).values():
|
|
413
|
+
detail = cli_entry.get("model_details", {}).get(model, {})
|
|
414
|
+
if detail:
|
|
415
|
+
break
|
|
416
|
+
profile: dict[str, Any] = {
|
|
417
|
+
dim: "unknown" for dim in ("reasoning", "speed", "cost",
|
|
418
|
+
"structured_output", "context",
|
|
419
|
+
"tool_use", "multimodal")
|
|
420
|
+
}
|
|
421
|
+
context = detail.get("context")
|
|
422
|
+
if context:
|
|
423
|
+
level = _context_from_label(context)
|
|
424
|
+
if level:
|
|
425
|
+
profile["context"] = level
|
|
426
|
+
if detail.get("images") in ("yes", "no"):
|
|
427
|
+
profile["multimodal"] = detail["images"]
|
|
428
|
+
profile["family"] = _family(model)
|
|
429
|
+
profile["verified_sources"] = sorted(k for k in detail if detail[k] not in ("", "-"))
|
|
430
|
+
return profile
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# --------------------------------------------------------------------------
|
|
434
|
+
# Phase 6-7 — Model Recommender (Role Requirements x Capability Profile)
|
|
435
|
+
# --------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _score_dimension(dimension: str, requirement: Any, capability: str) -> float:
|
|
438
|
+
"""1.0 meets / 0.5 one level below or unknown / 0.0 clearly below."""
|
|
439
|
+
if requirement is None:
|
|
440
|
+
return 1.0
|
|
441
|
+
if capability == "unknown":
|
|
442
|
+
return 0.5
|
|
443
|
+
rank = {"reasoning": _REASONING_RANK, "speed": _REASONING_RANK,
|
|
444
|
+
"context": _REASONING_RANK, "cost": _COST_RANK,
|
|
445
|
+
"structured_output": _QUAL_RANK, "tool_use": _QUAL_RANK,
|
|
446
|
+
"multimodal": _QUAL_RANK}[dimension]
|
|
447
|
+
req_rank = rank.get(requirement, 0)
|
|
448
|
+
cap_rank = rank.get(capability, 0)
|
|
449
|
+
if cap_rank >= req_rank:
|
|
450
|
+
return 1.0
|
|
451
|
+
if cap_rank >= req_rank - 1:
|
|
452
|
+
return 0.5
|
|
453
|
+
return 0.0
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def score_role_proposals(role: str, clis: list[str],
|
|
457
|
+
inventory: dict[str, Any]) -> list[dict[str, Any]]:
|
|
458
|
+
"""Score every (cli, model) candidate for one role; best first."""
|
|
459
|
+
reqs = ROLE_REQUIREMENTS[role]
|
|
460
|
+
dims = ("reasoning", "speed", "cost", "structured_output",
|
|
461
|
+
"context", "tool_use", "multimodal")
|
|
462
|
+
proposals: list[dict[str, Any]] = []
|
|
463
|
+
for cli in clis:
|
|
464
|
+
entry = inventory.get("clis", {}).get(cli, {})
|
|
465
|
+
for model in entry.get("models", []):
|
|
466
|
+
cap = capability_profile(model, inventory)
|
|
467
|
+
score = sum(_score_dimension(d, reqs[d], cap[d]) for d in dims) / len(dims)
|
|
468
|
+
notes: list[str] = []
|
|
469
|
+
if cap["context"] != "unknown":
|
|
470
|
+
notes.append(f"verified context {cap['context']}")
|
|
471
|
+
if cap["multimodal"] != "unknown":
|
|
472
|
+
notes.append(f"verified multimodal {cap['multimodal']}")
|
|
473
|
+
if reqs.get("independence"):
|
|
474
|
+
notes.append(f"family {cap['family']}")
|
|
475
|
+
if reqs.get("quantitative") == "preferred":
|
|
476
|
+
notes.append("quantitative output preferred (unverified)")
|
|
477
|
+
proposals.append({
|
|
478
|
+
"role": role, "cli": cli, "model": model, "family": cap["family"],
|
|
479
|
+
"score": round(score, 3), "notes": notes, "capabilities": cap,
|
|
480
|
+
})
|
|
481
|
+
proposals.sort(key=lambda p: (-p["score"],
|
|
482
|
+
-_CONTEXT_TIEBREAK[p["capabilities"]["context"]],
|
|
483
|
+
p["model"]))
|
|
484
|
+
return proposals
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _cost_class(model: str | None) -> str:
|
|
488
|
+
"""Cost class of a model; nothing verifiable -> 'unknown'."""
|
|
489
|
+
return "unknown"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def build_recommendation_table(allowed_clis: list[str],
|
|
493
|
+
inventory: dict[str, Any]) -> dict[str, Any]:
|
|
494
|
+
"""User-facing recommendation table (Phase 6-7).
|
|
495
|
+
|
|
496
|
+
One row per role: {role, cli, model, reason, task}. Also summary:
|
|
497
|
+
role_count, concurrency, cross_model_review, memory_bank, cost_class.
|
|
498
|
+
"""
|
|
499
|
+
recommendations: list[dict[str, Any]] = []
|
|
500
|
+
for role in ROLE_REQUIREMENTS:
|
|
501
|
+
proposals = score_role_proposals(role, allowed_clis, inventory)
|
|
502
|
+
if not proposals:
|
|
503
|
+
recommendations.append({
|
|
504
|
+
"role": role, "cli": None, "model": None, "family": "unknown",
|
|
505
|
+
"score": None,
|
|
506
|
+
"reason": "no verified models for allowed CLIs; user must "
|
|
507
|
+
"specify cli+model in the approval",
|
|
508
|
+
"task": ROLE_TASKS[role],
|
|
509
|
+
})
|
|
510
|
+
continue
|
|
511
|
+
best = proposals[0]
|
|
512
|
+
reasons = [f"score {best['score']:.2f} over verified capabilities"]
|
|
513
|
+
reasons.extend(best["notes"])
|
|
514
|
+
recommendations.append({
|
|
515
|
+
"role": role, "cli": best["cli"], "model": best["model"],
|
|
516
|
+
"family": best["family"], "score": best["score"],
|
|
517
|
+
"reason": "; ".join(reasons), "task": ROLE_TASKS[role],
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
families = {r["role"]: r.get("family") for r in recommendations}
|
|
521
|
+
skeptic_family = families.get("skeptic")
|
|
522
|
+
judge_family = families.get("evidence-judge")
|
|
523
|
+
cross_model = bool(skeptic_family and judge_family
|
|
524
|
+
and skeptic_family != judge_family)
|
|
525
|
+
cost_classes = {_cost_class(r["model"]) for r in recommendations if r["model"]}
|
|
526
|
+
cost_class = "Unknown" if not cost_classes or cost_classes == {"unknown"} \
|
|
527
|
+
else "/".join(sorted(cost_classes))
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
"recommendations": recommendations,
|
|
531
|
+
"summary": {
|
|
532
|
+
"role_count": len(recommendations),
|
|
533
|
+
"concurrency": len(recommendations),
|
|
534
|
+
"cross_model_review": cross_model,
|
|
535
|
+
"memory_bank": True,
|
|
536
|
+
"cost_class": cost_class,
|
|
537
|
+
"note": "scores use only verified facts; unknown capabilities "
|
|
538
|
+
"score 0.5 and never disqualify — the final cli/model "
|
|
539
|
+
"requires explicit user confirmation (safe_spawn gate).",
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# --------------------------------------------------------------------------
|
|
545
|
+
# Phase 8 — Approval Gate (agent_mcp_approval.json)
|
|
546
|
+
# --------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
def _role_mapping_hash(roles: dict[str, Any]) -> str:
|
|
549
|
+
"""Stable SHA-256 of the canonical role->{cli, model} mapping."""
|
|
550
|
+
payload = json.dumps(roles, sort_keys=True, ensure_ascii=False,
|
|
551
|
+
separators=(",", ":"))
|
|
552
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def build_approval_record(roles: dict[str, Any], allowed_clis: list[str],
|
|
556
|
+
*, budget: dict[str, Any] | None = None,
|
|
557
|
+
provider: str | None = None) -> dict[str, Any]:
|
|
558
|
+
"""Build the approval record the user confirms.
|
|
559
|
+
|
|
560
|
+
roles: {role: {"cli": str, "model": str}} — the exact mapping that will
|
|
561
|
+
be enforced by safe_spawn(). Any later change to this mapping (new CLI,
|
|
562
|
+
replaced model, new role, modified mapping) invalidates the hash and
|
|
563
|
+
requires re-confirmation.
|
|
564
|
+
"""
|
|
565
|
+
canonical = json.loads(json.dumps(roles, sort_keys=True))
|
|
566
|
+
return {
|
|
567
|
+
"approved": True,
|
|
568
|
+
"approved_at": datetime.now(timezone.utc).isoformat(),
|
|
569
|
+
"allowed_clis": sorted(allowed_clis),
|
|
570
|
+
"role_mapping_hash": _role_mapping_hash(canonical),
|
|
571
|
+
"roles": canonical,
|
|
572
|
+
"budget": budget or {},
|
|
573
|
+
"provider": provider,
|
|
574
|
+
"schema_version": 1,
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def write_approval(path: str | Path, roles: dict[str, Any], allowed_clis: list[str],
|
|
579
|
+
*, budget: dict[str, Any] | None = None,
|
|
580
|
+
provider: str | None = None) -> dict[str, Any]:
|
|
581
|
+
"""Persist the user-approved mapping to agent_mcp_approval.json."""
|
|
582
|
+
record = build_approval_record(roles, allowed_clis, budget=budget, provider=provider)
|
|
583
|
+
Path(path).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n",
|
|
584
|
+
encoding="utf-8")
|
|
585
|
+
return record
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def load_approval(path: str | Path) -> dict[str, Any] | None:
|
|
589
|
+
"""Load an approval file; missing/corrupt -> None (gate closed)."""
|
|
590
|
+
try:
|
|
591
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
592
|
+
except (OSError, json.JSONDecodeError):
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def is_approval_current(approval: dict[str, Any] | None, roles: dict[str, Any],
|
|
597
|
+
allowed_clis: list[str], *, budget: dict[str, Any] | None = None,
|
|
598
|
+
provider: str | None = None) -> tuple[bool, list[str]]:
|
|
599
|
+
"""True when the approval still matches the current proposal.
|
|
600
|
+
|
|
601
|
+
Returns (ok, changes); any change requires re-confirmation: 新增 CLI /
|
|
602
|
+
替换模型 / 新增角色 / 修改映射 / 提高 budget / 新 provider.
|
|
603
|
+
"""
|
|
604
|
+
changes: list[str] = []
|
|
605
|
+
if not approval or not approval.get("approved"):
|
|
606
|
+
changes.append("approval missing or not approved")
|
|
607
|
+
else:
|
|
608
|
+
stored = approval.get("roles") or {}
|
|
609
|
+
if approval.get("role_mapping_hash") != _role_mapping_hash(stored):
|
|
610
|
+
changes.append("approval file tampered (role_mapping_hash mismatch)")
|
|
611
|
+
if _role_mapping_hash(roles) != approval.get("role_mapping_hash"):
|
|
612
|
+
changes.append("role mapping changed (new CLI / replaced model / "
|
|
613
|
+
"new role / modified mapping)")
|
|
614
|
+
if sorted(approval.get("allowed_clis", [])) != sorted(allowed_clis):
|
|
615
|
+
changes.append("allowed_clis changed")
|
|
616
|
+
if budget is not None and approval.get("budget") != budget:
|
|
617
|
+
changes.append("budget changed")
|
|
618
|
+
if provider is not None and approval.get("provider") != provider:
|
|
619
|
+
changes.append("provider changed")
|
|
620
|
+
return (not changes, changes)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _check_approval(approval: dict[str, Any] | None, role: str, *,
|
|
624
|
+
target_cli: str | None = None, model: str | None = None,
|
|
625
|
+
allowed_clis: list[str] | None = None) -> str | None:
|
|
626
|
+
"""Gate check: None when allowed, else AGENT_MCP_APPROVAL_REQUIRED.
|
|
627
|
+
|
|
628
|
+
Order: approval exists -> hash matches -> CLI allowed -> model approved
|
|
629
|
+
-> role approved.
|
|
630
|
+
"""
|
|
631
|
+
if not approval or not approval.get("approved"):
|
|
632
|
+
return AGENT_MCP_APPROVAL_REQUIRED
|
|
633
|
+
stored = approval.get("roles") or {}
|
|
634
|
+
if approval.get("role_mapping_hash") != _role_mapping_hash(stored):
|
|
635
|
+
return AGENT_MCP_APPROVAL_REQUIRED
|
|
636
|
+
mapping = stored.get(role)
|
|
637
|
+
if mapping is None:
|
|
638
|
+
return AGENT_MCP_APPROVAL_REQUIRED
|
|
639
|
+
cli = mapping["cli"] if target_cli is None else target_cli
|
|
640
|
+
mdl = mapping["model"] if model is None else model
|
|
641
|
+
if not cli or not mdl:
|
|
642
|
+
return AGENT_MCP_APPROVAL_REQUIRED
|
|
643
|
+
if allowed_clis is not None and cli not in allowed_clis:
|
|
644
|
+
return AGENT_MCP_APPROVAL_REQUIRED
|
|
645
|
+
if cli != mapping.get("cli") or mdl != mapping.get("model"):
|
|
646
|
+
return AGENT_MCP_APPROVAL_REQUIRED
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# --------------------------------------------------------------------------
|
|
651
|
+
# Phase 9 — Spawn Guard: the ONLY spawn entry point
|
|
652
|
+
# --------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
def safe_spawn(role: str, prompt: str, approval: dict[str, Any] | None, *,
|
|
655
|
+
target_cli: str | None = None, model: str | None = None,
|
|
656
|
+
allowed_clis: list[str] | None = None,
|
|
657
|
+
cwd: str = ".", permission_mode: str = "plan",
|
|
658
|
+
context_mode: str = "compact", summary_chars: int | None = None,
|
|
659
|
+
timeout_seconds: int = 1800, cache_ttl: int = 0,
|
|
660
|
+
token_budget: int | None = None) -> dict[str, Any]:
|
|
661
|
+
"""UNIFIED spawn entry. Business code MUST NOT call spawn directly.
|
|
662
|
+
|
|
663
|
+
Gate order: approval exists -> proposal hash matches -> CLI allowed ->
|
|
664
|
+
model approved -> role approved -> build spawn payload. Any failure
|
|
665
|
+
returns {"status": AGENT_MCP_APPROVAL_REQUIRED, "spawn_call": None}.
|
|
666
|
+
CLI/model default to the approved role mapping; explicit values must
|
|
667
|
+
equal the approved ones.
|
|
668
|
+
"""
|
|
669
|
+
failure = _check_approval(approval, role, target_cli=target_cli, model=model,
|
|
670
|
+
allowed_clis=allowed_clis)
|
|
671
|
+
if failure is not None:
|
|
672
|
+
return {"status": failure, "spawn_call": None}
|
|
673
|
+
mapping = approval["roles"][role] # type: ignore[index]
|
|
674
|
+
return {
|
|
675
|
+
"status": "READY",
|
|
676
|
+
"spawn_call": build_spawn_call(
|
|
677
|
+
role, prompt, target_cli=mapping["cli"], model=mapping["model"],
|
|
678
|
+
cwd=cwd, permission_mode=permission_mode, context_mode=context_mode,
|
|
679
|
+
summary_chars=summary_chars, timeout_seconds=timeout_seconds,
|
|
680
|
+
cache_ttl=cache_ttl, token_budget=token_budget),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# --------------------------------------------------------------------------
|
|
685
|
+
# Payload builders (no hardcoded defaults — cli/model always explicit)
|
|
686
|
+
# --------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
def _routing_class(role: str) -> str:
|
|
689
|
+
"""fast / strong / independent class derived from role requirements."""
|
|
690
|
+
reqs = ROLE_REQUIREMENTS.get(role, {})
|
|
691
|
+
if reqs.get("independence"):
|
|
692
|
+
return "independent"
|
|
693
|
+
if reqs.get("speed") == "high":
|
|
694
|
+
return "fast"
|
|
695
|
+
return "strong"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def build_spawn_call(role: str, prompt: str, *, target_cli: str, model: str,
|
|
699
|
+
cwd: str = ".", permission_mode: str = "plan",
|
|
700
|
+
context_mode: str = "compact", summary_chars: int | None = None,
|
|
701
|
+
timeout_seconds: int = 1800, cache_ttl: int = 0,
|
|
702
|
+
token_budget: int | None = None) -> dict[str, Any]:
|
|
703
|
+
"""Build a spawn_agent tool-call payload matching the agent-mcp MCP contract.
|
|
704
|
+
|
|
705
|
+
target_cli and model are REQUIRED — there are no hardcoded defaults.
|
|
706
|
+
Approved values come from the user-confirmed mapping via safe_spawn().
|
|
707
|
+
"""
|
|
708
|
+
if role not in ROLE_REQUIREMENTS:
|
|
709
|
+
raise ValueError(f"unknown role {role!r}; known: {sorted(ROLE_REQUIREMENTS)}")
|
|
710
|
+
if not target_cli or not model:
|
|
711
|
+
raise ValueError("target_cli and model are required (no hardcoded "
|
|
712
|
+
"defaults); use safe_spawn() with a user approval")
|
|
713
|
+
routing_role = _routing_class(role)
|
|
714
|
+
if summary_chars is None:
|
|
715
|
+
summary_chars = 600 if routing_role == "fast" else 2000
|
|
716
|
+
return {
|
|
717
|
+
"tool": "spawn_agent",
|
|
718
|
+
"arguments": {
|
|
719
|
+
"task_name": role,
|
|
720
|
+
"prompt": prompt,
|
|
721
|
+
"target_cli": target_cli,
|
|
722
|
+
"model": model,
|
|
723
|
+
"cwd": cwd,
|
|
724
|
+
"permission_mode": permission_mode,
|
|
725
|
+
"context_mode": context_mode,
|
|
726
|
+
"summary_chars": summary_chars,
|
|
727
|
+
"timeout_seconds": timeout_seconds,
|
|
728
|
+
"cache_ttl": cache_ttl,
|
|
729
|
+
"token_budget": token_budget,
|
|
730
|
+
},
|
|
731
|
+
"routing_role": routing_role,
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def build_memory_store_call(content: str, *, kind: str = "research", key: str = "",
|
|
736
|
+
tags: list[str] | None = None) -> dict[str, Any]:
|
|
737
|
+
"""Build a memory_store payload (Memory Bank, 总体实施计划 §26 + v2 方案 §26)."""
|
|
738
|
+
return {
|
|
739
|
+
"tool": "memory_store",
|
|
740
|
+
"arguments": {
|
|
741
|
+
"content": content,
|
|
742
|
+
"kind": kind,
|
|
743
|
+
"key": key,
|
|
744
|
+
"tags": tags or [],
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def build_memory_recall_call(query: str, *, kind: str = "research", limit: int = 5) -> dict[str, Any]:
|
|
750
|
+
"""Build a memory_recall payload (Memory Bank)."""
|
|
751
|
+
return {
|
|
752
|
+
"tool": "memory_recall",
|
|
753
|
+
"arguments": {
|
|
754
|
+
"query": query,
|
|
755
|
+
"kind": kind,
|
|
756
|
+
"limit": limit,
|
|
757
|
+
},
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def cross_model_review(draft_verdict: dict[str, Any], *, target_cli: str, model: str,
|
|
762
|
+
approval: dict[str, Any] | None = None,
|
|
763
|
+
**_: Any) -> dict[str, Any]:
|
|
764
|
+
"""Cross-Model Review orchestration (总体实施计划 §25).
|
|
765
|
+
|
|
766
|
+
Flow: Primary Analysis -> Draft Verdict -> Independent Review -> Judge ->
|
|
767
|
+
Final Verdict. The independent reviewer runs under the 'skeptic' role with
|
|
768
|
+
the cli/model the user approved for it. Without explicit user approval the
|
|
769
|
+
review is NOT spawned: status AGENT_MCP_APPROVAL_REQUIRED. When agent-mcp
|
|
770
|
+
is unavailable, degrades to a native self-review plan marked
|
|
771
|
+
AGENT_MCP_UNAVAILABLE (no hard failure).
|
|
772
|
+
"""
|
|
773
|
+
report = detect_agent_mcp()
|
|
774
|
+
reviewer_prompt = (
|
|
775
|
+
"你是 EduEvidence 的独立交叉审核者(Independent Reviewer)。"
|
|
776
|
+
"以下是一份 Draft Verdict。请以独立模型视角审查:"
|
|
777
|
+
"agreement / disagreements / unsupported_claims / missed_counterevidence / "
|
|
778
|
+
"scope_violations / methodology_issues / confidence_adjustment / "
|
|
779
|
+
"required_revision / final_recommendation。"
|
|
780
|
+
"输出 CrossModelReview JSON(见 schemas/cross-model-review.schema.json)。\n\n"
|
|
781
|
+
f"DRAFT VERDICT:\n{json.dumps(draft_verdict, ensure_ascii=False, indent=2)}"
|
|
782
|
+
)
|
|
783
|
+
if not report["available"]:
|
|
784
|
+
return {
|
|
785
|
+
"status": AGENT_MCP_UNAVAILABLE,
|
|
786
|
+
"degraded_to": "native_self_review",
|
|
787
|
+
"note": "agent-mcp 未安装/不可达:退化为单 Agent 自审(Platform Native Mode),"
|
|
788
|
+
"不产生独立模型交叉审核。",
|
|
789
|
+
"review_plan": {"reviewer_prompt": reviewer_prompt,
|
|
790
|
+
"independent_model": model},
|
|
791
|
+
}
|
|
792
|
+
if approval is None:
|
|
793
|
+
return {
|
|
794
|
+
"status": AGENT_MCP_APPROVAL_REQUIRED,
|
|
795
|
+
"note": "cross-model review needs explicit user approval "
|
|
796
|
+
"(safe_spawn gate); nothing was spawned.",
|
|
797
|
+
"review_plan": {"reviewer_prompt": reviewer_prompt,
|
|
798
|
+
"independent_model": model},
|
|
799
|
+
}
|
|
800
|
+
result = safe_spawn("skeptic", reviewer_prompt, approval,
|
|
801
|
+
target_cli=target_cli, model=model,
|
|
802
|
+
context_mode="full", summary_chars=2000)
|
|
803
|
+
if result["status"] != "READY":
|
|
804
|
+
return {
|
|
805
|
+
"status": result["status"],
|
|
806
|
+
"review_plan": {"reviewer_prompt": reviewer_prompt,
|
|
807
|
+
"independent_model": model},
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
"status": "READY",
|
|
811
|
+
"spawn_call": result["spawn_call"],
|
|
812
|
+
"flow": ["primary_analysis", "draft_verdict", "independent_review",
|
|
813
|
+
"judge", "final_verdict"],
|
|
814
|
+
"report": report,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def memory_bank_guide() -> dict[str, str]:
|
|
819
|
+
"""Memory Bank fields for long-running teaching research (v2 方案 §26)."""
|
|
820
|
+
return {
|
|
821
|
+
"course_profile": "Course Profile",
|
|
822
|
+
"learner_profile": "Learner Profile",
|
|
823
|
+
"research_questions": "Research Questions",
|
|
824
|
+
"reviewed_sources": "Reviewed Sources",
|
|
825
|
+
"accepted_evidence": "Accepted Evidence",
|
|
826
|
+
"rejected_evidence": "Rejected Evidence",
|
|
827
|
+
"previous_verdict": "Previous Verdict",
|
|
828
|
+
"pilot_design": "Pilot Design",
|
|
829
|
+
"pilot_results": "Pilot Results",
|
|
830
|
+
"open_questions": "Open Questions",
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
if __name__ == "__main__":
|
|
835
|
+
import argparse
|
|
836
|
+
import sys
|
|
837
|
+
|
|
838
|
+
parser = argparse.ArgumentParser(description="Agent MCP detect / inventory / recommend")
|
|
839
|
+
parser.add_argument("--inventory", nargs="*", default=None, metavar="CLI",
|
|
840
|
+
help="scan these CLIs and write runs/<run_id>/model_inventory.json")
|
|
841
|
+
parser.add_argument("--recommend", nargs="*", default=None, metavar="CLI",
|
|
842
|
+
help="print the user-facing recommendation table for these CLIs")
|
|
843
|
+
args = parser.parse_args()
|
|
844
|
+
|
|
845
|
+
out: dict[str, Any] = {"detect": detect_agent_mcp()}
|
|
846
|
+
if args.inventory is not None:
|
|
847
|
+
path, inventory = write_model_inventory(args.inventory)
|
|
848
|
+
out["model_inventory"] = {"path": str(path),
|
|
849
|
+
"clis": {c: {"available": e["available"],
|
|
850
|
+
"models": e["models"]}
|
|
851
|
+
for c, e in inventory["clis"].items()}}
|
|
852
|
+
if args.recommend is not None:
|
|
853
|
+
_, inventory = write_model_inventory(args.recommend)
|
|
854
|
+
out["recommendation"] = build_recommendation_table(args.recommend, inventory)
|
|
855
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
856
|
+
sys.exit(0)
|