From 719442c7d8e82c0f10b983317cc00c2e594fc74c Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 19:58:30 +0800 Subject: [PATCH 01/12] governance: close traceability gate depth tasks and sync evidence Complete the remaining closeout items for enhance-doc-governance-traceability with fresh verification evidence and TODO truth alignment. Key changes: - extend check_governance_traceability.sh to: - validate frontmatter contracts for active and archived feature aggregation docs - enforce Claim Ledger active/done rows with non-pending change ids map to real OpenSpec tasks.md artifacts - enforce project_overall_todos Detail Claim Ref resolution and non-pending change-id consistency against agentscope_domain_execution_todos - tighten same-record TODO/change matching to Claim Ledger columns and ignore deprecated claims - add regression coverage in tests/unit/test_governance_traceability_gate.py for: - archived feature frontmatter missing required status - missing tasks artifact for active/done claim mappings - missing master->detail claim references - archived-change mapping acceptance path updates - mark OpenSpec change tasks 3.2/3.3/3.4/5.3 done with concrete evidence links - close feature aggregation doc status to done and refresh verification/risk narrative - sync project_overall_todos baseline and T0-6 claim/item status to done Rationale: - remove governance drift between claim ledgers, change artifacts, and feature evidence records - increase CI gate depth to catch stale or inconsistent closeout states before merge - keep docs-first governance records machine-checkable and auditable. --- .../enhance-doc-governance-traceability.md | 13 +- docs/todos/project_overall_todos.md | 11 +- .../tasks.md | 12 +- scripts/ci/check_governance_traceability.sh | 186 ++++++++++++++++-- .../unit/test_governance_traceability_gate.py | 88 ++++++++- 5 files changed, 281 insertions(+), 29 deletions(-) diff --git a/docs/features/enhance-doc-governance-traceability.md b/docs/features/enhance-doc-governance-traceability.md index bd661441..6d32549e 100644 --- a/docs/features/enhance-doc-governance-traceability.md +++ b/docs/features/enhance-doc-governance-traceability.md @@ -4,7 +4,7 @@ doc_kind: feature topics: ["documentation-governance", "traceability", "skills"] created: 2026-02-28 updated: 2026-03-04 -status: active +status: done mode: openspec --- @@ -36,8 +36,8 @@ Unify documentation management structure, lifecycle governance, and SOP-to-skill - `openspec validate enhance-doc-governance-traceability --type change --strict --json --no-interactive` ### Results -- `../../.venv/bin/python -m pytest -q tests/unit/test_governance_traceability_gate.py tests/unit/test_governance_evidence_truth_gate.py`: passed (`50 passed`) after extending the traceability gate regression suite to also cover stale active index entries, `Active Entries`-only membership checks, explicit checkpoint-to-skill pair rows, active/archive index path-family enforcement, README index-file exclusion for both active and archived entries, discrete `todo_ids` token matching, Claim Ledger-only TODO/change validation, same-record TODO/change validation, claim-scope range resolution, range-only claim-scope resolution without explicit todo tokens, full lifecycle checkpoint coverage, and date-prefixed archived change task discovery. -- `./scripts/ci/check_governance_traceability.sh`: passed against the real repository tree after tightening active/archive index membership to canonical sections, rejecting index entries outside the correct feature-doc path family, excluding `docs/features/README.md` and `docs/features/archive/README.md` from valid feature-entry targets, requiring explicit checkpoint-to-skill pair rows in Section 7, and resolving pilot `todo_ids` only through Claim Ledger records, including same-claim scope ranges where the TODO id is only implied by the claim range. +- `../../.venv/bin/python -m pytest -q tests/unit/test_governance_traceability_gate.py tests/unit/test_governance_evidence_truth_gate.py`: passed (`53 passed`) after extending the traceability gate regression suite to cover archived-feature frontmatter validation, TODO Claim (`active/done`) -> OpenSpec `tasks.md` completeness checks, and project master TODO `Detail Claim Ref` consistency against execution-board claims. +- `./scripts/ci/check_governance_traceability.sh`: passed against the real repository tree after extending the gate to validate frontmatter contract on both active and archived feature aggregation docs, enforce Claim Ledger-to-OpenSpec task artifact mapping for executable claims, and enforce master TODO/detail-board claim reference consistency checks. - `./scripts/ci/check_governance_evidence_truth.sh`: passed, confirming the new traceability assets do not break the existing evidence-first contract. - `openspec validate enhance-doc-governance-traceability --type change --strict --json --no-interactive`: passed (`1/1` change valid, `0` issues). @@ -69,17 +69,16 @@ Unify documentation management structure, lifecycle governance, and SOP-to-skill ### Structured Review Report - Changed Module Boundaries / Public API: governance scope only; no new runtime public API added. -- New State: adds one new repository gate script, one new gate test file, and canonical docs/index/template assets under `docs/features/`. +- New State: extends the existing governance traceability gate and regression suite with closeout checks for frontmatter scope, claim-to-task mapping, and master/detail claim consistency. - Concurrency / Timeout / Retry: no concurrency change; gate runs are single-process document scans with deterministic rerun behavior after fixes. - Side Effects and Idempotency: side effects are limited to CI/log output; repeated runs are idempotent against unchanged docs. -- Coverage and Residual Risk: template/index/skill-mapping/TODO-linkage checks are covered; residual risk is that broader frontmatter enforcement across `docs/guides/**` and `docs/design/**` is still pending. +- Coverage and Residual Risk: template/index/skill-mapping/TODO-linkage plus closeout-depth checks (`3.2-3.4`) are covered; residual risk is future expansion of frontmatter strictness from feature docs to additional governance doc families. ### Behavior Verification - Happy path: the repository now has a canonical feature aggregation template, explicit active/archive feature indexes, and a green traceability gate that resolves a pilot feature doc back to its TODO ledger and owning change-id through Claim Ledger records, including scope ranges such as `D2-1~D2-4, D4-1~D4-4`, even when the concrete TODO id does not appear elsewhere in the file. - Error/fallback path: the new gate fails deterministically when a feature doc is missing from the `## Active Entries` section, when an active/archive index entry points at the wrong doc family, when Section 7 keeps checkpoint names but drops the actual `checkpoint -> skill` mapping rows, or when `todo_ids` and `change_ids` only co-occur in detail-board/prose lines without a matching Claim Ledger record. ### Risks and Rollback -- Risk: `3.2-3.4` are still open, so the new gate does not yet enforce full frontmatter coverage for every governance-tracked doc family or full master-TODO/task completeness. - Risk: active/archive indexes are now explicit manual ledgers, so closeout changes that forget to update them will fail the new gate. - Rollback: remove `governance-traceability` from `.github/workflows/ci-gate.yml` and revert the template/index additions if the new gate produces unexpected false positives. @@ -103,4 +102,4 @@ Unify documentation management structure, lifecycle governance, and SOP-to-skill - `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/175#discussion_r2881421738` ## Next Milestone -Implement the remaining CI depth tasks: widen frontmatter enforcement beyond feature docs and add machine-checkable TODO/task and master-TODO/change-slice consistency checks before closeout. +Promote `p0-gate` to branch protection required check and then archive this change entry in the next closeout pass. diff --git a/docs/todos/project_overall_todos.md b/docs/todos/project_overall_todos.md index 92518ae8..3a2a5a65 100644 --- a/docs/todos/project_overall_todos.md +++ b/docs/todos/project_overall_todos.md @@ -28,7 +28,7 @@ | CLM-20260304-AG7 | T5-4 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `CLM-20260304-D6 + CLM-20260304-D8` | TODO 级认领;切片仅在 AgentScope 详细区维护。 | | CLM-20260304-AG8 | T1-2 + T1-5 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | 项目层高复杂 TODO 组待分配;切片见详细拆分区。 | | CLM-20260304-AG9 | T2-3 + T2-4 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | 项目层治理 TODO 组待分配;切片见详细拆分区。 | -| CLM-20260304-AG10 | T0-6 | lang | active | 2026-03-04 | 2026-03-11 | `pending` | `—` | 下一项 fix:P0 红灯且无上游依赖,先修复 `search_file` 路径契约回归。 | +| CLM-20260304-AG10 | T0-6 | lang | done | 2026-03-04 | 2026-03-11 | `pending` | `—` | `search_file` 路径契约回归已修复并回归通过。 | | CLM-20260304-AG11 | T0-4 + T0-5 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | P0 未完成项补齐认领声明:facade 合规修复与失败映射责任化待分配。 | | CLM-20260304-AG12 | T1-3 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | P1 未完成项补齐认领声明:`ISecurityBoundary` 接入待分配。 | | CLM-20260304-AG13 | T2-1(剩余范围) + T2-2 + T5-1 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | Layer-2 策略补齐:D5 子范围已完成,剩余上下文融合/多阶段 prompt/session 补齐待分配。 | @@ -42,9 +42,8 @@ ## 2. 当前基线 -- 测试基线(2026-03-03):`.venv/bin/pytest -q` => `1 failed, 642 passed, 12 skipped, 1 warning`。 +- 测试基线(2026-03-04):`.venv/bin/pytest -q` => `676 passed, 12 skipped, 1 warning`。 - 关键问题聚类: - - `search_file` 输出路径契约回归:实现返回绝对路径,测试期望相对路径(`tests/unit/test_v4_file_tools.py::test_search_file_finds_matching_paths`)。 - 全量 TODO / Claim / Feature 状态存在漂移(已 merge 或 archived 的 change 仍标记 active/draft)。 - 若干 package `__init__.py` 不满足 facade 约束。 - 设计已定义但实现未闭环:plan attempt 隔离(snapshot/rollback)、Context 检索融合、完整 HITL 语义、P0 conformance gate 与治理自动化门禁。 @@ -70,9 +69,9 @@ Status: `planned` - [ ] T0-5 建立“失败测试 -> 责任模块 -> owner”映射并例行巡检。 Status: `planned` -- [ ] T0-6 修复 `search_file` 输出路径契约回归(绝对路径 vs 相对路径)。 - Status: `active` - Evidence: `.venv/bin/pytest -q` 失败用例 `tests/unit/test_v4_file_tools.py::test_search_file_finds_matching_paths`;实现位置 `dare_framework/tool/_internal/tools/search_file.py` +- [x] T0-6 修复 `search_file` 输出路径契约回归(绝对路径 vs 相对路径)。 + Status: `done` + Evidence: `.venv/bin/pytest -q tests/unit/test_v4_file_tools.py::test_search_file_finds_matching_paths` => `1 passed`;`.venv/bin/pytest -q` => `676 passed, 12 skipped, 1 warning` Last Updated: `2026-03-04` 验收: diff --git a/openspec/changes/enhance-doc-governance-traceability/tasks.md b/openspec/changes/enhance-doc-governance-traceability/tasks.md index 85dd3b6f..352a25ee 100644 --- a/openspec/changes/enhance-doc-governance-traceability/tasks.md +++ b/openspec/changes/enhance-doc-governance-traceability/tasks.md @@ -28,9 +28,12 @@ - [x] 3.1 Implement or extend CI checks to validate aggregation entry existence when governance-scoped files change. Evidence: `scripts/ci/check_governance_traceability.sh`, `tests/unit/test_governance_traceability_gate.py`, and `.github/workflows/ci-gate.yml`. -- [ ] 3.2 Implement or extend CI checks to validate required frontmatter fields for governance-tracked docs. -- [ ] 3.3 Implement or extend CI checks to validate gap/TODO -> OpenSpec task mapping completeness. -- [ ] 3.4 Implement or extend CI checks to validate master TODO -> OpenSpec change-slice mapping consistency. +- [x] 3.2 Implement or extend CI checks to validate required frontmatter fields for governance-tracked docs. + Evidence: `scripts/ci/check_governance_traceability.sh` now validates required frontmatter keys and value contract for active + archived feature aggregation docs; `tests/unit/test_governance_traceability_gate.py::test_gate_fails_when_archived_feature_doc_frontmatter_is_missing_required_field`. +- [x] 3.3 Implement or extend CI checks to validate gap/TODO -> OpenSpec task mapping completeness. + Evidence: `scripts/ci/check_governance_traceability.sh` now validates Claim Ledger rows (`active/done` + non-`pending`) resolve to real OpenSpec `tasks.md` artifacts; `tests/unit/test_governance_traceability_gate.py::test_gate_fails_when_done_or_active_claim_has_no_tasks_artifact`. +- [x] 3.4 Implement or extend CI checks to validate master TODO -> OpenSpec change-slice mapping consistency. + Evidence: `scripts/ci/check_governance_traceability.sh` now validates `project_overall_todos.md` `Detail Claim Ref` entries resolve in `agentscope_domain_execution_todos.md` and keep non-pending change-id consistency; `tests/unit/test_governance_traceability_gate.py::test_gate_fails_when_project_claim_detail_ref_is_missing`. - [x] 3.5 Implement evidence truth structural gate (`scripts/ci/check_governance_evidence_truth.sh`) and wire it into `ci-gate`. Evidence: `scripts/ci/check_governance_evidence_truth.sh`, `tests/unit/test_governance_evidence_truth_gate.py`, and `.github/workflows/ci-gate.yml`. @@ -50,4 +53,5 @@ - [x] 5.1 Backfill one active governance change using the new aggregation + frontmatter + skill mapping contract as pilot evidence. Evidence: `docs/features/agentscope-d2-d4-thinking-transport.md` now declares `todo_ids` that resolve back to `docs/todos/agentscope_domain_execution_todos.md`. - [x] 5.2 Run governance check scripts and capture passing command output in PR evidence. -- [ ] 5.3 Update TODO/archive records and mark this OpenSpec change as complete with evidence links. +- [x] 5.3 Update TODO/archive records and mark this OpenSpec change as complete with evidence links. + Evidence: `docs/features/enhance-doc-governance-traceability.md` updated with closeout command results and residual-risk refresh; `docs/todos/project_overall_todos.md` updated with `T0-6` status/evidence alignment and baseline refresh. diff --git a/scripts/ci/check_governance_traceability.sh b/scripts/ci/check_governance_traceability.sh index 6da5b8c6..b5b6beed 100755 --- a/scripts/ci/check_governance_traceability.sh +++ b/scripts/ci/check_governance_traceability.sh @@ -190,6 +190,57 @@ trim_whitespace() { sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' <<<"$1" } +normalize_markdown_cell() { + local value="$1" + value="$(trim_whitespace "$value")" + if [[ "$value" == '~~'*'~~' ]]; then + value="${value#\~\~}" + value="${value%\~\~}" + fi + if [[ "$value" == '`'*'`' ]]; then + value="${value#\`}" + value="${value%\`}" + fi + trim_whitespace "$value" +} + +markdown_table_column() { + local row="$1" + local column="$2" + awk -F'|' -v column="$column" ' + NF >= column + 1 { + value = $(column + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + } + ' <<<"$row" +} + +claim_ledger_rows() { + local file="$1" + extract_markdown_section_matching "$file" "Claim Ledger" \ + | awk ' + /^\|/ { + if ($0 ~ /^\|[[:space:]]*Claim ID[[:space:]]*\|/) next + if ($0 ~ /^\|[[:space:]-]+\|/) next + print + } + ' +} + +change_has_tasks_artifact() { + local change_id="$1" + if [[ -f "openspec/changes/$change_id/tasks.md" ]]; then + return 0 + fi + if [[ ! -d "openspec/changes/archive" ]]; then + return 1 + fi + find openspec/changes/archive -type f \ + \( -path "*/$change_id/tasks.md" -o -path "*/????-??-??-$change_id/tasks.md" \) \ + | grep -q . +} + normalize_scope_segment() { local segment="$1" segment="$(trim_whitespace "$segment")" @@ -261,21 +312,25 @@ claim_ledger_has_tokens_in_same_record() { local file="$1" local first_token="$2" local second_token="$3" - local line scope_field + local line scope_field change_field status_field while IFS= read -r line; do - [[ "$line" == \|* ]] || continue - if ! text_has_discrete_token "$line" "$second_token"; then + scope_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 2)")" + status_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 4)")" + change_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 7)")" + + if [[ "$status_field" == "deprecated" ]]; then continue fi - if text_has_discrete_token "$line" "$first_token"; then - return 0 + + if ! text_has_discrete_token "$change_field" "$second_token"; then + continue fi - scope_field="$(awk -F'|' 'NF >= 4 {field=$3; gsub(/^[[:space:]]+|[[:space:]]+$/, "", field); print field}' <<<"$line")" + if [[ -n "$scope_field" ]] && scope_contains_todo_id "$scope_field" "$first_token"; then return 0 fi - done < <(extract_markdown_section_matching "$file" "Claim Ledger") + done < <(claim_ledger_rows "$file") return 1 } @@ -382,8 +437,8 @@ check_checkpoint_skill_mapping() { check_feature_doc() { local file="$1" - local frontmatter mode doc_kind key change_id todo_id matched candidate - local change_ids_raw todo_ids_raw + local frontmatter mode doc_kind key change_id todo_id matched candidate status created updated + local change_ids_raw todo_ids_raw topics_raw frontmatter="$(extract_frontmatter "$file")" if [[ -z "$frontmatter" ]]; then @@ -405,7 +460,37 @@ check_feature_doc() { failures=$((failures + 1)) fi + status="$(trim_quotes "$(frontmatter_scalar "$frontmatter" "status")")" + if [[ -n "$status" && ! "$status" =~ ^(draft|active|done|archived)$ ]]; then + log "invalid frontmatter status '$status' in $file" + failures=$((failures + 1)) + fi + + created="$(trim_quotes "$(frontmatter_scalar "$frontmatter" "created")")" + if [[ -n "$created" && ! "$created" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + log "invalid frontmatter created date in $file: $created" + failures=$((failures + 1)) + fi + + updated="$(trim_quotes "$(frontmatter_scalar "$frontmatter" "updated")")" + if [[ -n "$updated" && ! "$updated" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + log "invalid frontmatter updated date in $file: $updated" + failures=$((failures + 1)) + fi + + topics_raw="$(frontmatter_list_items "$frontmatter" "topics")" + if [[ -z "$topics_raw" ]]; then + log "missing required frontmatter field 'topics' in $file" + failures=$((failures + 1)) + fi + mode="$(trim_quotes "$(frontmatter_scalar "$frontmatter" "mode")")" + if [[ "$mode" != "openspec" && "$mode" != "todo_fallback" ]]; then + log "invalid frontmatter mode '$mode' in $file" + failures=$((failures + 1)) + return + fi + if [[ "$mode" == "todo_fallback" ]]; then if [[ -z "$(frontmatter_scalar "$frontmatter" "topic_slug")" ]]; then log "missing required frontmatter field 'topic_slug' in $file" @@ -423,8 +508,7 @@ check_feature_doc() { while IFS= read -r change_id; do [[ -z "$change_id" ]] && continue - if [[ ! -f "openspec/changes/$change_id/tasks.md" ]] && \ - ! find openspec/changes/archive -type f \( -path "*/$change_id/tasks.md" -o -path "*/????-??-??-$change_id/tasks.md" \) | grep -q .; then + if ! change_has_tasks_artifact "$change_id"; then log "missing OpenSpec tasks artifact for change_id '$change_id' declared in $file" failures=$((failures + 1)) fi @@ -459,6 +543,79 @@ check_feature_doc() { done <<<"$todo_ids_raw" } +check_todo_claim_to_openspec_task_mapping() { + local file line claim_id status_field change_field + + while IFS= read -r file; do + while IFS= read -r line; do + claim_id="$(normalize_markdown_cell "$(markdown_table_column "$line" 1)")" + status_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 4)")" + change_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 7)")" + + [[ -z "$claim_id" || -z "$status_field" ]] && continue + [[ "$status_field" != "active" && "$status_field" != "done" ]] && continue + [[ -z "$change_field" || "$change_field" == "pending" ]] && continue + + if ! change_has_tasks_artifact "$change_field"; then + log "missing OpenSpec tasks mapping for TODO claim $claim_id in $file: $change_field" + failures=$((failures + 1)) + fi + done < <(claim_ledger_rows "$file") + done < <(find docs/todos -maxdepth 1 -type f -name '*.md' ! -name 'README.md' | sort) +} + +check_master_todo_slice_mapping_consistency() { + local project_file="docs/todos/project_overall_todos.md" + local detail_file="docs/todos/agentscope_domain_execution_todos.md" + local line status_field claim_id change_field detail_refs ref detail_change + local detail_claim_map + + if [[ ! -f "$project_file" || ! -f "$detail_file" ]]; then + return + fi + + detail_claim_map="$(mktemp)" + trap 'rm -f "$detail_claim_map"' RETURN + + while IFS= read -r line; do + claim_id="$(normalize_markdown_cell "$(markdown_table_column "$line" 1)")" + status_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 4)")" + change_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 7)")" + + [[ -z "$claim_id" ]] && continue + [[ "$status_field" == "deprecated" ]] && continue + printf '%s|%s\n' "$claim_id" "$change_field" >>"$detail_claim_map" + done < <(claim_ledger_rows "$detail_file") + + while IFS= read -r line; do + status_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 4)")" + [[ "$status_field" == "deprecated" ]] && continue + + change_field="$(normalize_markdown_cell "$(markdown_table_column "$line" 7)")" + detail_refs="$(normalize_markdown_cell "$(markdown_table_column "$line" 8)")" + + [[ -z "$detail_refs" || "$detail_refs" == "—" ]] && continue + + while IFS= read -r ref; do + [[ -z "$ref" ]] && continue + detail_change="$(awk -F'|' -v target="$ref" '$1 == target {print $2; exit}' "$detail_claim_map")" + if [[ -z "$detail_change" ]]; then + log "missing Detail Claim Ref mapping in $project_file: $ref" + failures=$((failures + 1)) + continue + fi + + if [[ -n "$change_field" && "$change_field" != "pending" && -n "$detail_change" && "$detail_change" != "pending" && "$detail_change" != "$change_field" ]]; then + log "inconsistent OpenSpec change between $project_file and $detail_file for ref $ref" + failures=$((failures + 1)) + fi + done < <(grep -oE 'CLM-[A-Za-z0-9-]+' <<<"$detail_refs") + done < <(claim_ledger_rows "$project_file") + + rm -f "$detail_claim_map" + trap - RETURN +} + check_feature_indexes check_checkpoint_skill_mapping @@ -466,6 +623,13 @@ while IFS= read -r file; do check_feature_doc "$file" done < <(find docs/features -maxdepth 1 -type f -name '*.md' ! -name 'README.md' | sort) +while IFS= read -r file; do + check_feature_doc "$file" +done < <(find docs/features/archive -maxdepth 1 -type f -name '*.md' ! -name 'README.md' | sort) + +check_todo_claim_to_openspec_task_mapping +check_master_todo_slice_mapping_consistency + if [[ $failures -gt 0 ]]; then log "failed with $failures issue(s)" exit 1 diff --git a/tests/unit/test_governance_traceability_gate.py b/tests/unit/test_governance_traceability_gate.py index 410abff9..c69cd4fe 100644 --- a/tests/unit/test_governance_traceability_gate.py +++ b/tests/unit/test_governance_traceability_gate.py @@ -86,7 +86,21 @@ def _base_tree(root: Path) -> None: - Move docs here only after closeout. """, ) - _write(root / "docs" / "features" / "archive" / "archived-change.md", "# archived\n") + _write( + root / "docs" / "features" / "archive" / "archived-change.md", + """--- +change_ids: ["archived-change"] +doc_kind: feature +topics: ["governance", "archive"] +created: 2026-03-01 +updated: 2026-03-01 +status: archived +mode: openspec +--- + +# Feature: archived-change +""", + ) _write( root / "docs" / "features" / "templates" / "feature_aggregation_template.md", """# Feature Aggregation Template @@ -113,11 +127,34 @@ def _base_tree(root: Path) -> None: | Claim ID | TODO Scope | Owner | Status | Declared At | Expires At | OpenSpec Change | Notes | |---|---|---|---|---|---|---|---| | CLM-DEMO | D2-1~D2-2 | demo | active | 2026-03-03 | 2026-03-10 | `demo-change` | demo | +""", + ) + _write( + root / "docs" / "todos" / "project_overall_todos.md", + """# Demo Project Overall TODO + +## 1.1 认领声明(Claim Ledger) + +| Claim ID | TODO Scope | Owner | Status | Declared At | Expires At | OpenSpec Change | Detail Claim Ref | Notes | +|---|---|---|---|---|---|---|---|---| +| CLM-AG1 | T5-2 | demo | done | 2026-03-03 | 2026-03-10 | `demo-change` | `CLM-D9` | demo | +""", + ) + _write( + root / "docs" / "todos" / "agentscope_domain_execution_todos.md", + """# Demo AgentScope Board + +## 0.1 认领声明(Claim Ledger) + +| Claim ID | TODO Scope | Owner | Status | Declared At | Expires At | OpenSpec Change | Project Claim Ref | Notes | +|---|---|---|---|---|---|---|---|---| +| CLM-D9 | D9-1~D9-2 | demo | done | 2026-03-03 | 2026-03-10 | `demo-change` | `CLM-AG1` | demo | """, ) _write(root / "openspec" / "changes" / "demo-change" / "proposal.md", "# proposal\n") _write(root / "openspec" / "changes" / "demo-change" / "design.md", "# design\n") _write(root / "openspec" / "changes" / "demo-change" / "tasks.md", "- [ ] demo\n") + _write(root / "openspec" / "changes" / "archive" / "2026-03-01-archived-change" / "tasks.md", "- [x] archived\n") _write( root / "docs" / "governance" / "Documentation_Management_Model.md", """# Documentation Management Model @@ -166,6 +203,19 @@ def test_gate_fails_when_feature_template_is_missing(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("missing feature aggregation template", result.stdout) + def test_gate_fails_when_archived_feature_doc_frontmatter_is_missing_required_field(self) -> None: + def mutate(root: Path) -> None: + archived = root / "docs" / "features" / "archive" / "archived-change.md" + archived.write_text( + archived.read_text(encoding="utf-8").replace("status: archived\n", ""), + encoding="utf-8", + ) + + result = self._run_gate(mutate) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing required frontmatter field 'status'", result.stdout) + def test_gate_fails_when_active_feature_doc_is_not_indexed(self) -> None: def mutate(root: Path) -> None: readme = root / "docs" / "features" / "README.md" @@ -405,6 +455,32 @@ def mutate(root: Path) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("missing TODO mapping for feature doc", result.stdout) + def test_gate_fails_when_done_or_active_claim_has_no_tasks_artifact(self) -> None: + def mutate(root: Path) -> None: + project_doc = root / "docs" / "todos" / "project_overall_todos.md" + project_doc.write_text( + project_doc.read_text(encoding="utf-8").replace("`demo-change`", "`missing-change`"), + encoding="utf-8", + ) + + result = self._run_gate(mutate) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing OpenSpec tasks mapping for TODO claim", result.stdout) + + def test_gate_fails_when_project_claim_detail_ref_is_missing(self) -> None: + def mutate(root: Path) -> None: + project_doc = root / "docs" / "todos" / "project_overall_todos.md" + project_doc.write_text( + project_doc.read_text(encoding="utf-8").replace("`CLM-D9`", "`CLM-D404`"), + encoding="utf-8", + ) + + result = self._run_gate(mutate) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing Detail Claim Ref mapping in docs/todos/project_overall_todos.md", result.stdout) + def test_gate_accepts_date_prefixed_archived_change_tasks(self) -> None: def mutate(root: Path) -> None: feature_doc = root / "docs" / "features" / "demo-change.md" @@ -417,6 +493,16 @@ def mutate(root: Path) -> None: todo_doc.read_text(encoding="utf-8").replace("`demo-change`", "`archived-change`"), encoding="utf-8", ) + project_doc = root / "docs" / "todos" / "project_overall_todos.md" + project_doc.write_text( + project_doc.read_text(encoding="utf-8").replace("`demo-change`", "`archived-change`"), + encoding="utf-8", + ) + detail_doc = root / "docs" / "todos" / "agentscope_domain_execution_todos.md" + detail_doc.write_text( + detail_doc.read_text(encoding="utf-8").replace("`demo-change`", "`archived-change`"), + encoding="utf-8", + ) active_change_dir = root / "openspec" / "changes" / "demo-change" for path in active_change_dir.iterdir(): path.unlink() From 288f902fcda64e143a29ca879feac3f49b25db55 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 20:01:18 +0800 Subject: [PATCH 02/12] docs: add p0-gate required-check rollout checklist Prepare deterministic closeout guidance for p0-conformance-gate task 3.2, which depends on repo-admin branch protection changes outside the repository. Key changes: - add a dedicated 'P0-Gate Required Check Rollout (Admin Checklist)' section to docs/governance/branch-protection.md - preconditions for promotion readiness - exact execution steps in branch protection/rulesets - acceptance criteria (blocked-on-fail and unblocked-on-pass) - evidence bundle required for governance closeout - update docs/features/p0-conformance-gate.md - refresh frontmatter updated date - record the new rollout-checklist artifact in Results - tighten Next Milestone to execute checklist and backfill evidence links - update openspec/changes/p0-conformance-gate/tasks.md item 3.2 note - link to the rollout checklist - define closeout evidence expectation (settings + blocked/pass run links) Rationale: - task 3.2 cannot be closed by code-only changes; it needs admin execution plus auditable proof - documenting one canonical checklist reduces ambiguity, prevents partial rollout, and keeps docs-first governance evidence machine-reviewable. --- docs/features/p0-conformance-gate.md | 5 +-- docs/governance/branch-protection.md | 33 +++++++++++++++++++ openspec/changes/p0-conformance-gate/tasks.md | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/features/p0-conformance-gate.md b/docs/features/p0-conformance-gate.md index feb6a290..0b21135e 100644 --- a/docs/features/p0-conformance-gate.md +++ b/docs/features/p0-conformance-gate.md @@ -3,7 +3,7 @@ change_ids: ["p0-conformance-gate"] doc_kind: feature topics: ["p0", "conformance", "ci-gate", "runtime-validation"] created: 2026-03-03 -updated: 2026-03-03 +updated: 2026-03-04 status: active mode: openspec --- @@ -66,6 +66,7 @@ mode: openspec - `openspec validate p0-conformance-gate --type change --strict --json --no-interactive`: passed (`1/1` change valid, `0` issues) after restoring the missing active feature aggregation record for this change. - `./scripts/ci/check_governance_evidence_truth.sh`: initially failed because the restored feature doc lacked historical PR/review links; after linking the already-landed P0 evidence PRs, the governance gate passed, and remained green after task `1.1-1.3` synchronized the gate scope matrix and rollout contract into the active docs/spec set. - `./scripts/ci/check_governance_evidence_truth.sh`: remained green after adding `docs/guides/P0_Gate_Runbook.md` plus the new navigation links in `docs/README.md` and `docs/guides/Team_Agent_Collab_Playbook.md`, confirming the operationalization docs did not break the governance acceptance pack. +- `docs/governance/branch-protection.md`: now includes a dedicated `p0-gate` required-check rollout checklist (preconditions, execution, acceptance, and evidence fields) so task `3.2` can be closed with deterministic admin-side proof. ### Behavior Verification @@ -105,4 +106,4 @@ mode: openspec ## Next Milestone -Schedule the repo-admin follow-up for task `3.2`: add `p0-gate` to the protected-branch required checks / ruleset after this change merges. +Execute the admin rollout checklist in `docs/governance/branch-protection.md` (`P0-Gate Required Check Rollout`) and then close task `3.2` with settings + blocked/pass run evidence links. diff --git a/docs/governance/branch-protection.md b/docs/governance/branch-protection.md index ece18bd6..edb8eabb 100644 --- a/docs/governance/branch-protection.md +++ b/docs/governance/branch-protection.md @@ -36,6 +36,39 @@ If your GitHub plan supports merge queue: - summary contract: `p0-gate` must emit deterministic category labels and failing test/module pointers before it can become a required branch check - repo-admin action: after this change merges, add `p0-gate` to the protected-branch required checks list / ruleset +## P0-Gate Required Check Rollout (Admin Checklist) + +> Tracking scope: `openspec/changes/p0-conformance-gate/tasks.md` item `3.2`. + +### Preconditions + +1. `p0-conformance-gate` change has been merged to `main`. +2. Latest `main` run shows `p0-gate` green with all three categories reported. +3. `.github/workflows/ci-gate.yml` still contains job name `p0-gate` (required-check name must match exactly). + +### Execution Steps + +1. Open `Settings -> Branches` (or repository Rulesets) for `main`. +2. Enable `Require status checks to pass before merging` if not already enabled. +3. Add `p0-gate` to required status checks. +4. Keep existing required checks (`lint`, `build`, and other active phases) unchanged. +5. Save the branch protection / ruleset change. + +### Acceptance + +1. Open a test PR targeting `main` and force one `p0-gate` anchor failure; merge must be blocked. +2. Fix the failure and rerun; merge must be unblocked only after `p0-gate` is green. +3. Verify merge-queue path (if enabled) also enforces `p0-gate` on `merge_group`. + +### Evidence to Record + +1. Branch protection / ruleset screenshot or settings URL proving `p0-gate` is required. +2. One blocked PR run URL where `p0-gate` failed. +3. One unblocked PR run URL where `p0-gate` passed. +4. Update: + - `openspec/changes/p0-conformance-gate/tasks.md` item `3.2` to `done` with links + - `docs/features/p0-conformance-gate.md` `Results` and `Next Milestone` + ## Fallback if Merge Queue Is Unavailable Use pre-merge combined checks: diff --git a/openspec/changes/p0-conformance-gate/tasks.md b/openspec/changes/p0-conformance-gate/tasks.md index a958434f..e49be59b 100644 --- a/openspec/changes/p0-conformance-gate/tasks.md +++ b/openspec/changes/p0-conformance-gate/tasks.md @@ -37,7 +37,7 @@ Commands: `../../.venv/bin/python scripts/ci/p0_gate.py` => `p0-gate: PASS` Last Updated: `2026-03-03` - [ ] 3.2 将 `p0-gate` 配置为主分支 required check。 - Note: 该项需要 GitHub branch protection / ruleset 管理员权限;当前仓库内已完成 job 名称与 rollout 文档对齐,但尚未执行远端仓库设置。 + Note: 该项需要 GitHub branch protection / ruleset 管理员权限;当前仓库内已完成 job 名称与 rollout 文档对齐,但尚未执行远端仓库设置。执行清单见 `docs/governance/branch-protection.md` 的 `P0-Gate Required Check Rollout (Admin Checklist)` 章节,关闭时需补 settings + blocked/pass run 证据链接。 - [x] 3.3 输出标准化门禁报告(通过率、失败类型、建议排查点)。 Evidence: `scripts/ci/p0_gate.py` 的 `format_summary()` 已固定 `PASS/FAIL + category label + failing tests + modules + action` 文本格式,并写入 `GITHUB_STEP_SUMMARY`;`tests/unit/test_p0_gate_ci.py` 锁定该 summary contract。 Commands: `../../.venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py` => `3 passed`;`../../.venv/bin/python scripts/ci/p0_gate.py` => `p0-gate: PASS` From b83bdc95a5a7bcb86f48a9753b420cb2d4e85c34 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 20:18:50 +0800 Subject: [PATCH 03/12] refactor(model): remove wildcard facade export and enforce initializer import rule Advance T0-4 (__init__.py facade compliance) by removing wildcard exports from the public model facade and hardening regression checks. Key changes: - replace wildcard import in dare_framework/model/__init__.py - from: from dare_framework.model.adapters import * - to: explicit imports of OpenAIModelAdapter and OpenRouterModelAdapter - extend tests/unit/test_package_initializers_facade_pattern.py with Rule 5 - package initializers now explicitly prohibit star imports (import *) - preserves existing docstring/definition/assignment/import facade constraints Why: - wildcard exports make public API surface implicit and fragile - facade initializers should remain deterministic, reviewable, and intentionally exported - adding a concrete rule prevents future regressions from reintroducing wildcard initializer exports Verification: - .venv/bin/python -m pytest -q tests/unit/test_package_initializers_facade_pattern.py tests/unit/test_default_model_adapter_manager.py tests/unit/test_prompt_store.py tests/unit/test_context_implementation.py => 30 passed - smoke import check confirms both adapters remain available from dare_framework.model. --- dare_framework/model/__init__.py | 2 +- tests/unit/test_package_initializers_facade_pattern.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/dare_framework/model/__init__.py b/dare_framework/model/__init__.py index 59a6d201..3afc7a8c 100644 --- a/dare_framework/model/__init__.py +++ b/dare_framework/model/__init__.py @@ -9,7 +9,7 @@ from dare_framework.model.builtin_prompt_loader import BuiltInPromptLoader from dare_framework.model.filesystem_prompt_loader import FileSystemPromptLoader from dare_framework.model.layered_prompt_store import LayeredPromptStore -from dare_framework.model.adapters import * +from dare_framework.model.adapters import OpenAIModelAdapter, OpenRouterModelAdapter __all__ = [ "IModelAdapter", diff --git a/tests/unit/test_package_initializers_facade_pattern.py b/tests/unit/test_package_initializers_facade_pattern.py index 3990969b..a7380de2 100644 --- a/tests/unit/test_package_initializers_facade_pattern.py +++ b/tests/unit/test_package_initializers_facade_pattern.py @@ -12,6 +12,7 @@ def test_package_initializers_follow_facade_pattern() -> None: 2. No class or function definitions. 3. Assignments allowed only for metadata (e.g., __all__, __version__). 4. Imports allowed for re-exporting. + 5. Star imports are prohibited. """ repo_root = Path(__file__).resolve().parents[2] package_root = repo_root / "dare_framework" @@ -68,6 +69,10 @@ def test_package_initializers_follow_facade_pattern() -> None: violations.append(f"{path.relative_to(repo_root)}: Prohibited assignment (only __all__/__version__ etc allowed)") # Rule 4: Imports are allowed (for re-exporting) + if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names): + violations.append(f"{path.relative_to(repo_root)}: Prohibited star import in package initializer") + continue + if isinstance(node, (ast.Import, ast.ImportFrom)): continue From af2f06b8285e4ffb976266e41d0bc8992032e488 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 20:46:13 +0800 Subject: [PATCH 04/12] ci(p0): enforce failure-test ownership mapping and routine checks Advance T0-5 by making the "failed test -> responsibility module -> owner" mapping explicit, machine-checkable, and continuously enforced in CI. What changed: - scripts/ci/p0_gate.py - extend CategorySpec with owner field - assign owner for each P0 category - extend FAIL summary contract to print owner alongside tests/modules/action - scripts/ci/check_test_failure_ownership.py (new) - add deterministic validator for P0 failure ownership mapping - enforce non-empty tests/modules/owner - enforce owner format as GitHub handle (@...) - detect duplicate test selector mappings across categories - provide CI-friendly pass/fail output - tests/unit/test_p0_gate_ci.py - update summary contract assertion to require owner line - tests/unit/test_check_test_failure_ownership.py (new) - add regression coverage for valid mapping, duplicate selector detection, and missing owner rejection - .github/workflows/ci-gate.yml - add failure-ownership-map job running python scripts/ci/check_test_failure_ownership.py - docs/guides/P0_Gate_Runbook.md - document ownership mapping health-check command and CI job linkage - openspec/changes/p0-conformance-gate/tasks.md - refresh task 3.3 evidence to include owner contract and routine ownership mapping check - docs/features/p0-conformance-gate.md - append result evidence for ownership-map check and CI integration - docs/todos/project_overall_todos.md - mark T0-5 done with evidence and set AG11 claim active (T0-4 ongoing) Why: - before this change, p0-gate triage exposed failing tests and modules but had no explicit owner signal - mapping integrity was implicit in code and not continuously validated - adding a dedicated CI check turns ownership mapping into an auditable, enforceable governance contract Verification: - .venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py tests/unit/test_check_test_failure_ownership.py => 7 passed - .venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py => 4 passed - python scripts/ci/check_test_failure_ownership.py => [failure-ownership] passed - .venv/bin/python scripts/ci/p0_gate.py => p0-gate: PASS (all categories 0 failures) - ./scripts/ci/check_governance_traceability.sh => passed - ./scripts/ci/check_governance_evidence_truth.sh => passed - openspec validate p0-conformance-gate --type change --strict --json --no-interactive => valid (1/1, 0 issues) --- .github/workflows/ci-gate.yml | 10 +++ docs/features/p0-conformance-gate.md | 2 + docs/guides/P0_Gate_Runbook.md | 17 +++++ docs/todos/project_overall_todos.md | 8 ++- openspec/changes/p0-conformance-gate/tasks.md | 6 +- scripts/ci/check_test_failure_ownership.py | 67 +++++++++++++++++++ scripts/ci/p0_gate.py | 5 ++ .../unit/test_check_test_failure_ownership.py | 53 +++++++++++++++ tests/unit/test_p0_gate_ci.py | 3 + 9 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 scripts/ci/check_test_failure_ownership.py create mode 100644 tests/unit/test_check_test_failure_ownership.py diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index b2b3a917..4a0e7be5 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -153,6 +153,16 @@ jobs: - name: Check governance traceability contract (template + indexes + TODO linkage) run: ./scripts/ci/check_governance_traceability.sh + failure-ownership-map: + name: failure-ownership-map + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify failure-test ownership mapping + run: python scripts/ci/check_test_failure_ownership.py + p0-gate: name: p0-gate runs-on: ubuntu-latest diff --git a/docs/features/p0-conformance-gate.md b/docs/features/p0-conformance-gate.md index 0b21135e..e4cb3c9a 100644 --- a/docs/features/p0-conformance-gate.md +++ b/docs/features/p0-conformance-gate.md @@ -44,6 +44,7 @@ mode: openspec - `../../.venv/bin/python -m pytest -q tests/unit/test_dare_agent_security_policy_gate.py tests/unit/test_dare_agent_security_boundary.py tests/unit/test_five_layer_agent.py` - `../../.venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py` - `../../.venv/bin/python scripts/ci/p0_gate.py` +- `python scripts/ci/check_test_failure_ownership.py` - `openspec validate p0-conformance-gate --type change --strict --json --no-interactive` - `./scripts/ci/check_governance_evidence_truth.sh` @@ -63,6 +64,7 @@ mode: openspec `- STEP_EXEC_REGRESSION: 0 failures` `- AUDIT_CHAIN_REGRESSION: 0 failures` which confirms the repository now has a single deterministic command entrypoint for the three frozen P0 categories. +- `python scripts/ci/check_test_failure_ownership.py`: passed and now enforces category-level failure ownership integrity (`test selector -> module scope -> owner`) as a machine-checkable precondition; `.github/workflows/ci-gate.yml` now runs this as `failure-ownership-map`. - `openspec validate p0-conformance-gate --type change --strict --json --no-interactive`: passed (`1/1` change valid, `0` issues) after restoring the missing active feature aggregation record for this change. - `./scripts/ci/check_governance_evidence_truth.sh`: initially failed because the restored feature doc lacked historical PR/review links; after linking the already-landed P0 evidence PRs, the governance gate passed, and remained green after task `1.1-1.3` synchronized the gate scope matrix and rollout contract into the active docs/spec set. - `./scripts/ci/check_governance_evidence_truth.sh`: remained green after adding `docs/guides/P0_Gate_Runbook.md` plus the new navigation links in `docs/README.md` and `docs/guides/Team_Agent_Collab_Playbook.md`, confirming the operationalization docs did not break the governance acceptance pack. diff --git a/docs/guides/P0_Gate_Runbook.md b/docs/guides/P0_Gate_Runbook.md index 19bb3854..30f28d88 100644 --- a/docs/guides/P0_Gate_Runbook.md +++ b/docs/guides/P0_Gate_Runbook.md @@ -21,6 +21,23 @@ p0-gate: PASS The same command is used by `.github/workflows/ci-gate.yml` job `p0-gate`. +## 1.1 Ownership Mapping Health Check + +Run ownership-map巡检 from repository root: + +```bash +python scripts/ci/check_test_failure_ownership.py +``` + +Expected success output starts with: + +```text +[failure-ownership] passed +``` + +This command is used by `.github/workflows/ci-gate.yml` job `failure-ownership-map` and enforces the +`失败测试 -> 责任模块 -> owner` mapping integrity for `p0-gate` categories. + ## 2. Category Mapping ### SECURITY_REGRESSION diff --git a/docs/todos/project_overall_todos.md b/docs/todos/project_overall_todos.md index 3a2a5a65..eef15f57 100644 --- a/docs/todos/project_overall_todos.md +++ b/docs/todos/project_overall_todos.md @@ -29,7 +29,7 @@ | CLM-20260304-AG8 | T1-2 + T1-5 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | 项目层高复杂 TODO 组待分配;切片见详细拆分区。 | | CLM-20260304-AG9 | T2-3 + T2-4 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | 项目层治理 TODO 组待分配;切片见详细拆分区。 | | CLM-20260304-AG10 | T0-6 | lang | done | 2026-03-04 | 2026-03-11 | `pending` | `—` | `search_file` 路径契约回归已修复并回归通过。 | -| CLM-20260304-AG11 | T0-4 + T0-5 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | P0 未完成项补齐认领声明:facade 合规修复与失败映射责任化待分配。 | +| CLM-20260304-AG11 | T0-4 + T0-5 | lang | active | 2026-03-04 | 2026-03-11 | `pending` | `—` | T0-5 已完成首版映射与 CI 巡检;T0-4 facade 合规收尾中。 | | CLM-20260304-AG12 | T1-3 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | P1 未完成项补齐认领声明:`ISecurityBoundary` 接入待分配。 | | CLM-20260304-AG13 | T2-1(剩余范围) + T2-2 + T5-1 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | Layer-2 策略补齐:D5 子范围已完成,剩余上下文融合/多阶段 prompt/session 补齐待分配。 | | CLM-20260304-AG14 | T3-1 + T3-2 + T3-3 + T3-4 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | Layer-3 工程化与文档治理未完成项补齐认领声明。 | @@ -67,8 +67,10 @@ Last Updated: `2026-03-01` - [ ] T0-4 修复 `__init__.py` facade 违规并固化回归检查。 Status: `planned` -- [ ] T0-5 建立“失败测试 -> 责任模块 -> owner”映射并例行巡检。 - Status: `planned` +- [x] T0-5 建立“失败测试 -> 责任模块 -> owner”映射并例行巡检。 + Status: `done` + Evidence: `scripts/ci/p0_gate.py`(category summary 新增 `owner`);`scripts/ci/check_test_failure_ownership.py`(映射完整性巡检脚本);`.github/workflows/ci-gate.yml`(新增 `failure-ownership-map` job);`docs/guides/P0_Gate_Runbook.md`(命令与排障入口更新);`.venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py tests/unit/test_check_test_failure_ownership.py` => `7 passed` + Last Updated: `2026-03-04` - [x] T0-6 修复 `search_file` 输出路径契约回归(绝对路径 vs 相对路径)。 Status: `done` Evidence: `.venv/bin/pytest -q tests/unit/test_v4_file_tools.py::test_search_file_finds_matching_paths` => `1 passed`;`.venv/bin/pytest -q` => `676 passed, 12 skipped, 1 warning` diff --git a/openspec/changes/p0-conformance-gate/tasks.md b/openspec/changes/p0-conformance-gate/tasks.md index e49be59b..5ebdb06e 100644 --- a/openspec/changes/p0-conformance-gate/tasks.md +++ b/openspec/changes/p0-conformance-gate/tasks.md @@ -2,7 +2,7 @@ - [x] 1.1 定义 `p0-gate` 覆盖的三类不变量与验收阈值。 Evidence: `openspec/changes/p0-conformance-gate/design.md` 已固化 `SECURITY_REGRESSION` / `STEP_EXEC_REGRESSION` / `AUDIT_CHAIN_REGRESSION` 三类 category matrix,并为 required-mode promotion 定义 “单次运行全绿” 阈值。 - Last Updated: `2026-03-03` + Last Updated: `2026-03-04` - [x] 1.2 明确每类不变量对应的测试文件与责任模块。 Evidence: `openspec/changes/p0-conformance-gate/design.md` 的 Gate Scope Matrix 已列出现有 anchor suites、后续必须补的 integration anchors,以及各 category 的 primary ownership modules。 Last Updated: `2026-03-03` @@ -39,8 +39,8 @@ - [ ] 3.2 将 `p0-gate` 配置为主分支 required check。 Note: 该项需要 GitHub branch protection / ruleset 管理员权限;当前仓库内已完成 job 名称与 rollout 文档对齐,但尚未执行远端仓库设置。执行清单见 `docs/governance/branch-protection.md` 的 `P0-Gate Required Check Rollout (Admin Checklist)` 章节,关闭时需补 settings + blocked/pass run 证据链接。 - [x] 3.3 输出标准化门禁报告(通过率、失败类型、建议排查点)。 - Evidence: `scripts/ci/p0_gate.py` 的 `format_summary()` 已固定 `PASS/FAIL + category label + failing tests + modules + action` 文本格式,并写入 `GITHUB_STEP_SUMMARY`;`tests/unit/test_p0_gate_ci.py` 锁定该 summary contract。 - Commands: `../../.venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py` => `3 passed`;`../../.venv/bin/python scripts/ci/p0_gate.py` => `p0-gate: PASS` + Evidence: `scripts/ci/p0_gate.py` 的 `format_summary()` 已固定 `PASS/FAIL + category label + failing tests + modules + owner + action` 文本格式,并写入 `GITHUB_STEP_SUMMARY`;`scripts/ci/check_test_failure_ownership.py` + `.github/workflows/ci-gate.yml` `failure-ownership-map` job 形成例行映射巡检;`tests/unit/test_p0_gate_ci.py` 锁定 summary contract。 + Commands: `../../.venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py` => `4 passed`;`python scripts/ci/check_test_failure_ownership.py` => `[failure-ownership] passed`;`../../.venv/bin/python scripts/ci/p0_gate.py` => `p0-gate: PASS` Last Updated: `2026-03-03` ## 4. Operationalization diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py new file mode 100644 index 00000000..3f6f25a8 --- /dev/null +++ b/scripts/ci/check_test_failure_ownership.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Validate failure-test ownership mapping used by the P0 gate.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if __package__ in {None, ""}: + # Allow direct execution: `python scripts/ci/check_test_failure_ownership.py` + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.p0_gate import DEFAULT_CATEGORY_SPECS, CategorySpec + + +def validate_category_specs(specs: list[CategorySpec]) -> list[str]: + """Return validation issues for failure ownership mapping specs.""" + issues: list[str] = [] + test_to_label: dict[str, str] = {} + + for spec in specs: + if not spec.tests: + issues.append(f"{spec.label}: missing test selectors") + if not spec.modules: + issues.append(f"{spec.label}: missing module ownership scope") + + owner = spec.owner.strip() + if not owner: + issues.append(f"{spec.label}: missing owner") + elif not owner.startswith("@"): + issues.append(f"{spec.label}: owner must be a GitHub handle (starts with '@')") + + for selector in spec.tests: + normalized = selector.strip() + if not normalized: + issues.append(f"{spec.label}: empty test selector") + continue + existing = test_to_label.get(normalized) + if existing and existing != spec.label: + issues.append( + "duplicate test selector mapping: " + f"{normalized} is mapped by both {existing} and {spec.label}" + ) + continue + test_to_label[normalized] = spec.label + + return issues + + +def main() -> int: + specs = list(DEFAULT_CATEGORY_SPECS) + issues = validate_category_specs(specs) + + if issues: + print("[failure-ownership] failed") + for issue in issues: + print(f"- {issue}") + return 1 + + print("[failure-ownership] passed") + for spec in specs: + print(f"- {spec.label}: owner={spec.owner}; tests={len(spec.tests)}; modules={len(spec.modules)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/p0_gate.py b/scripts/ci/p0_gate.py index cd28ee19..b61d5562 100644 --- a/scripts/ci/p0_gate.py +++ b/scripts/ci/p0_gate.py @@ -18,6 +18,7 @@ class CategorySpec: label: str tests: list[str] modules: list[str] + owner: str action: str @@ -47,6 +48,7 @@ class CategoryResult: "examples/05-dare-coding-agent-enhanced/cli.py", "examples/06-dare-coding-agent-mcp/cli.py", ], + owner="@zts212653", action="inspect trust/policy/approval flow before tool invocation", ), CategorySpec( @@ -61,6 +63,7 @@ class CategoryResult: "dare_framework/agent/_internal/execute_engine.py", "dare_framework/plan", ], + owner="@zts212653", action="inspect step execution order, fail-fast handling, and validated-plan routing", ), CategorySpec( @@ -76,6 +79,7 @@ class CategoryResult: "dare_framework/observability/_internal/event_trace_bridge.py", "dare_framework/agent/builder.py", ], + owner="@zts212653", action="inspect SQLite event append/hash-chain/replay wiring and trace-aware event-log bridging", ), ) @@ -104,6 +108,7 @@ def format_summary(results: list[CategoryResult]) -> str: f"- {result.spec.label}", f" tests: {failed_tests}", f" modules: {modules}", + f" owner: {result.spec.owner}", f" action: {result.spec.action}", ] ) diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py new file mode 100644 index 00000000..c3ecc2ac --- /dev/null +++ b/tests/unit/test_check_test_failure_ownership.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from scripts.ci.check_test_failure_ownership import validate_category_specs +from scripts.ci.p0_gate import CategorySpec + + +def _spec( + label: str, + *, + tests: list[str] | None = None, + modules: list[str] | None = None, + owner: str = "@zts212653", +) -> CategorySpec: + return CategorySpec( + label=label, + tests=tests or ["tests/example.py::test_case"], + modules=modules or ["dare_framework/example.py"], + owner=owner, + action="inspect category", + ) + + +def test_validate_category_specs_accepts_valid_mapping() -> None: + issues = validate_category_specs( + [ + _spec("SECURITY_REGRESSION", tests=["tests/a.py::test_x"]), + _spec("STEP_EXEC_REGRESSION", tests=["tests/b.py::test_y"]), + ] + ) + + assert issues == [] + + +def test_validate_category_specs_rejects_duplicate_test_mapping() -> None: + issues = validate_category_specs( + [ + _spec("SECURITY_REGRESSION", tests=["tests/a.py::test_x"]), + _spec("STEP_EXEC_REGRESSION", tests=["tests/a.py::test_x"]), + ] + ) + + assert any("duplicate test selector mapping" in issue for issue in issues) + + +def test_validate_category_specs_rejects_missing_owner() -> None: + issues = validate_category_specs( + [ + _spec("SECURITY_REGRESSION", owner=""), + ] + ) + + assert any("missing owner" in issue for issue in issues) + diff --git a/tests/unit/test_p0_gate_ci.py b/tests/unit/test_p0_gate_ci.py index 61b1ec2d..e7e391e7 100644 --- a/tests/unit/test_p0_gate_ci.py +++ b/tests/unit/test_p0_gate_ci.py @@ -7,12 +7,14 @@ def _spec( label: str, *, modules: list[str] | None = None, + owner: str = "@zts212653", action: str = "inspect category", ) -> CategorySpec: return CategorySpec( label=label, tests=["tests/example.py::test_case"], modules=modules or ["module.one", "module.two"], + owner=owner, action=action, ) @@ -89,6 +91,7 @@ def test_format_summary_reports_failures_with_modules_and_action() -> None: "- STEP_EXEC_REGRESSION", " tests: tests/integration/test_p0_conformance_gate.py::test_step_driven_session_stops_after_first_failed_step", " modules: dare_framework/agent/dare_agent.py, dare_framework/agent/_internal/execute_engine.py", + " owner: @zts212653", " action: inspect step execution order and fail-fast handling", ] ) From 0095b35d500700861eab13d1900f1e271c4d5fdb Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 21:04:34 +0800 Subject: [PATCH 05/12] refactor(facade): remove direct _internal imports from public initializers Continue T0-4 (__init__.py facade compliance) by enforcing and implementing a stricter public-facade boundary contract. What changed: - strengthened tests/unit/test_package_initializers_facade_pattern.py - add Rule 6: public package initializers must not directly import from `._internal` - keep existing guardrails (docstring-only facade, no defs, metadata-only assignments, no star imports) - introduced explicit public export layers for supported defaults: - dare_framework/checkpoint/defaults.py - dare_framework/embedding/defaults.py - dare_framework/event/defaults.py - dare_framework/hook/defaults.py - dare_framework/plan/defaults.py - dare_framework/transport/adapters.py - updated public facades to import from those public layers instead of direct `_internal` paths: - dare_framework/checkpoint/__init__.py - dare_framework/embedding/__init__.py - dare_framework/event/__init__.py - dare_framework/hook/__init__.py - dare_framework/plan/__init__.py - dare_framework/security/__init__.py (switch DefaultSecurityBoundary import to security.impl) - dare_framework/transport/__init__.py - synced governance docs: - docs/todos/project_overall_todos.md: mark T0-4 done and close AG11 claim - docs/design/Framework_MinSurface_Review.md: add implementation note for this compliance batch Why: - direct `_internal` imports in public `__init__.py` files blur API boundaries and couple callers to implementation topology - introducing explicit public export layers preserves compatibility while tightening facade discipline - codifying the rule in tests prevents regressions from reintroducing direct internal coupling Verification: - .venv/bin/python -m pytest -q tests/unit/test_package_initializers_facade_pattern.py => 1 passed - .venv/bin/python -m pytest -q tests/unit/test_package_initializers_facade_pattern.py tests/unit/test_event_sqlite_event_log.py tests/unit/test_security_boundary.py tests/unit/test_embedding_openai_adapter.py tests/unit/test_default_planner.py tests/unit/test_default_remediator.py tests/unit/test_hook_extension_point_governance.py tests/unit/test_transport_channel.py tests/unit/test_execution_control.py => 52 passed - import smoke checks for moved default exports (event/embedding/plan/transport/security/hook) => ok - ./scripts/ci/check_governance_traceability.sh => passed - ./scripts/ci/check_governance_evidence_truth.sh => passed --- dare_framework/checkpoint/__init__.py | 14 ++++++------- dare_framework/checkpoint/defaults.py | 21 +++++++++++++++++++ dare_framework/embedding/__init__.py | 6 +++--- dare_framework/embedding/defaults.py | 5 +++++ dare_framework/event/__init__.py | 2 +- dare_framework/event/defaults.py | 5 +++++ dare_framework/hook/__init__.py | 2 +- dare_framework/hook/defaults.py | 5 +++++ dare_framework/plan/__init__.py | 3 +-- dare_framework/plan/defaults.py | 6 ++++++ dare_framework/security/__init__.py | 7 +++++-- dare_framework/transport/__init__.py | 2 +- dare_framework/transport/adapters.py | 15 +++++++++++++ docs/design/Framework_MinSurface_Review.md | 5 +++++ docs/todos/project_overall_todos.md | 9 ++++---- ...est_package_initializers_facade_pattern.py | 21 +++++++++++++++++++ 16 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 dare_framework/checkpoint/defaults.py create mode 100644 dare_framework/embedding/defaults.py create mode 100644 dare_framework/event/defaults.py create mode 100644 dare_framework/hook/defaults.py create mode 100644 dare_framework/plan/defaults.py create mode 100644 dare_framework/transport/adapters.py diff --git a/dare_framework/checkpoint/__init__.py b/dare_framework/checkpoint/__init__.py index a6e5432d..d7471314 100644 --- a/dare_framework/checkpoint/__init__.py +++ b/dare_framework/checkpoint/__init__.py @@ -11,15 +11,13 @@ ScopePresets, ) from dare_framework.checkpoint.factory import create_default_save_restore -from dare_framework.checkpoint._internal.memory_store import MemoryCheckpointStore -from dare_framework.checkpoint._internal.save_restore import DefaultCheckpointSaveRestore -from dare_framework.checkpoint._internal.contributors.stm_contributor import StmContributor -from dare_framework.checkpoint._internal.contributors.workspace_git_contributor import ( - WorkspaceGitContributor, -) -from dare_framework.checkpoint._internal.contributors.session_contributor import ( - SessionStateContributor, +from dare_framework.checkpoint.defaults import ( + DefaultCheckpointSaveRestore, + MemoryCheckpointStore, SessionContextContributor, + SessionStateContributor, + StmContributor, + WorkspaceGitContributor, ) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py new file mode 100644 index 00000000..10686b81 --- /dev/null +++ b/dare_framework/checkpoint/defaults.py @@ -0,0 +1,21 @@ +"""Supported default checkpoint implementations and contributors.""" + +from dare_framework.checkpoint._internal.contributors.session_contributor import ( + SessionContextContributor, + SessionStateContributor, +) +from dare_framework.checkpoint._internal.contributors.stm_contributor import StmContributor +from dare_framework.checkpoint._internal.contributors.workspace_git_contributor import ( + WorkspaceGitContributor, +) +from dare_framework.checkpoint._internal.memory_store import MemoryCheckpointStore +from dare_framework.checkpoint._internal.save_restore import DefaultCheckpointSaveRestore + +__all__ = [ + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", +] diff --git a/dare_framework/embedding/__init__.py b/dare_framework/embedding/__init__.py index 8d1de770..60d4b447 100644 --- a/dare_framework/embedding/__init__.py +++ b/dare_framework/embedding/__init__.py @@ -1,8 +1,8 @@ """embedding domain facade.""" -from dare_framework.embedding.interfaces import IEmbeddingAdapter -from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult -from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter +from dare_framework.embedding.interfaces import IEmbeddingAdapter +from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult +from dare_framework.embedding.defaults import OpenAIEmbeddingAdapter __all__ = [ "IEmbeddingAdapter", diff --git a/dare_framework/embedding/defaults.py b/dare_framework/embedding/defaults.py new file mode 100644 index 00000000..88e38524 --- /dev/null +++ b/dare_framework/embedding/defaults.py @@ -0,0 +1,5 @@ +"""Supported default embedding implementations.""" + +from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter + +__all__ = ["OpenAIEmbeddingAdapter"] diff --git a/dare_framework/event/__init__.py b/dare_framework/event/__init__.py index a7ac3831..703a2740 100644 --- a/dare_framework/event/__init__.py +++ b/dare_framework/event/__init__.py @@ -1,6 +1,6 @@ """event domain facade.""" -from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog +from dare_framework.event.defaults import DefaultEventLog, SQLiteEventLog from dare_framework.event.kernel import IEventLog from dare_framework.event.types import Event, RuntimeSnapshot diff --git a/dare_framework/event/defaults.py b/dare_framework/event/defaults.py new file mode 100644 index 00000000..d5eee182 --- /dev/null +++ b/dare_framework/event/defaults.py @@ -0,0 +1,5 @@ +"""Supported default event-log implementations.""" + +from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog + +__all__ = ["SQLiteEventLog", "DefaultEventLog"] diff --git a/dare_framework/hook/__init__.py b/dare_framework/hook/__init__.py index 3621b4f1..1d1d3c25 100644 --- a/dare_framework/hook/__init__.py +++ b/dare_framework/hook/__init__.py @@ -2,7 +2,7 @@ from dare_framework.hook.interfaces import IHookManager from dare_framework.hook.kernel import IExtensionPoint, IHook, HookFn -from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint +from dare_framework.hook.defaults import HookExtensionPoint from dare_framework.hook.types import HookDecision, HookEnvelope, HookPhase, HookResult __all__ = [ diff --git a/dare_framework/hook/defaults.py b/dare_framework/hook/defaults.py new file mode 100644 index 00000000..2037a192 --- /dev/null +++ b/dare_framework/hook/defaults.py @@ -0,0 +1,5 @@ +"""Supported default extension-point implementation.""" + +from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint + +__all__ = ["HookExtensionPoint"] diff --git a/dare_framework/plan/__init__.py b/dare_framework/plan/__init__.py index 9daa9392..59716d99 100644 --- a/dare_framework/plan/__init__.py +++ b/dare_framework/plan/__init__.py @@ -23,8 +23,7 @@ ValidatedStep, VerifyResult, ) -from dare_framework.plan._internal.default_planner import DefaultPlanner -from dare_framework.plan._internal.default_remediator import DefaultRemediator +from dare_framework.plan.defaults import DefaultPlanner, DefaultRemediator __all__ = [ # Interfaces diff --git a/dare_framework/plan/defaults.py b/dare_framework/plan/defaults.py new file mode 100644 index 00000000..6f0edc33 --- /dev/null +++ b/dare_framework/plan/defaults.py @@ -0,0 +1,6 @@ +"""Supported default planner/remediator implementations.""" + +from dare_framework.plan._internal.default_planner import DefaultPlanner +from dare_framework.plan._internal.default_remediator import DefaultRemediator + +__all__ = ["DefaultPlanner", "DefaultRemediator"] diff --git a/dare_framework/security/__init__.py b/dare_framework/security/__init__.py index add8eb8a..ade0d6f6 100644 --- a/dare_framework/security/__init__.py +++ b/dare_framework/security/__init__.py @@ -1,6 +1,5 @@ """Security domain facade.""" -from dare_framework.security._internal.default_security_boundary import DefaultSecurityBoundary from dare_framework.security.errors import ( SECURITY_APPROVAL_MANAGER_MISSING, SECURITY_POLICY_CHECK_FAILED, @@ -8,7 +7,11 @@ SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, ) -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + DefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.kernel import ISecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput diff --git a/dare_framework/transport/__init__.py b/dare_framework/transport/__init__.py index 69197374..326be795 100644 --- a/dare_framework/transport/__init__.py +++ b/dare_framework/transport/__init__.py @@ -16,7 +16,7 @@ ActionHandlerDispatcher, ResourceAction, ) -from dare_framework.transport._internal import ( +from dare_framework.transport.adapters import ( DefaultAgentChannel, DirectClientChannel, StdioClientChannel, diff --git a/dare_framework/transport/adapters.py b/dare_framework/transport/adapters.py new file mode 100644 index 00000000..9dadb2c9 --- /dev/null +++ b/dare_framework/transport/adapters.py @@ -0,0 +1,15 @@ +"""Supported default transport channel adapters.""" + +from dare_framework.transport._internal import ( + DefaultAgentChannel, + DirectClientChannel, + StdioClientChannel, + WebSocketClientChannel, +) + +__all__ = [ + "DefaultAgentChannel", + "DirectClientChannel", + "StdioClientChannel", + "WebSocketClientChannel", +] diff --git a/docs/design/Framework_MinSurface_Review.md b/docs/design/Framework_MinSurface_Review.md index 4d80b193..bc8ec18a 100644 --- a/docs/design/Framework_MinSurface_Review.md +++ b/docs/design/Framework_MinSurface_Review.md @@ -268,3 +268,8 @@ MCP adapters, etc. - Tight coupling to concrete classes prevents safe refactors. - External users rely on internal DTOs that must then be supported forever. - Increased likelihood of breaking changes with each internal improvement. + +## 5. Implementation Note (2026-03-04) +- T0-4 facade compliance batch moved public-domain `__init__.py` imports away from direct `._internal` references into explicit public export layers (`defaults.py`, `impl`, `adapters`). +- The following facades now import only public modules directly: `checkpoint`, `embedding`, `event`, `hook`, `plan`, `security`, `transport`. +- A regression rule was added in `tests/unit/test_package_initializers_facade_pattern.py` to block future direct `._internal` imports from public facades. diff --git a/docs/todos/project_overall_todos.md b/docs/todos/project_overall_todos.md index eef15f57..d6f6568e 100644 --- a/docs/todos/project_overall_todos.md +++ b/docs/todos/project_overall_todos.md @@ -29,7 +29,7 @@ | CLM-20260304-AG8 | T1-2 + T1-5 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | 项目层高复杂 TODO 组待分配;切片见详细拆分区。 | | CLM-20260304-AG9 | T2-3 + T2-4 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | 项目层治理 TODO 组待分配;切片见详细拆分区。 | | CLM-20260304-AG10 | T0-6 | lang | done | 2026-03-04 | 2026-03-11 | `pending` | `—` | `search_file` 路径契约回归已修复并回归通过。 | -| CLM-20260304-AG11 | T0-4 + T0-5 | lang | active | 2026-03-04 | 2026-03-11 | `pending` | `—` | T0-5 已完成首版映射与 CI 巡检;T0-4 facade 合规收尾中。 | +| CLM-20260304-AG11 | T0-4 + T0-5 | lang | done | 2026-03-04 | 2026-03-11 | `pending` | `—` | T0-4/T0-5 均已完成并纳入回归与 CI 巡检。 | | CLM-20260304-AG12 | T1-3 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | P1 未完成项补齐认领声明:`ISecurityBoundary` 接入待分配。 | | CLM-20260304-AG13 | T2-1(剩余范围) + T2-2 + T5-1 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | Layer-2 策略补齐:D5 子范围已完成,剩余上下文融合/多阶段 prompt/session 补齐待分配。 | | CLM-20260304-AG14 | T3-1 + T3-2 + T3-3 + T3-4 | | planned | 2026-03-04 | 2026-03-11 | `pending` | `—` | Layer-3 工程化与文档治理未完成项补齐认领声明。 | @@ -45,7 +45,6 @@ - 测试基线(2026-03-04):`.venv/bin/pytest -q` => `676 passed, 12 skipped, 1 warning`。 - 关键问题聚类: - 全量 TODO / Claim / Feature 状态存在漂移(已 merge 或 archived 的 change 仍标记 active/draft)。 - - 若干 package `__init__.py` 不满足 facade 约束。 - 设计已定义但实现未闭环:plan attempt 隔离(snapshot/rollback)、Context 检索融合、完整 HITL 语义、P0 conformance gate 与治理自动化门禁。 ## 3. 优先级路线图 @@ -65,8 +64,10 @@ Evidence: `examples/05-dare-coding-agent-enhanced/cli.py`、`examples/06-dare-coding-agent-mcp/cli.py`(`_invoke_approval_action` 统一为 `invoke(action, **params)` 形态,移除 `params={...}` 调用分叉);`tests/unit/test_examples_cli.py`、`tests/unit/test_examples_cli_mcp.py`(新增 kwargs 调用契约回归) Commands: `.venv/bin/pytest -q tests/unit/test_examples_cli.py tests/unit/test_examples_cli_mcp.py` => `22 passed, 1 warning` Last Updated: `2026-03-01` -- [ ] T0-4 修复 `__init__.py` facade 违规并固化回归检查。 - Status: `planned` +- [x] T0-4 修复 `__init__.py` facade 违规并固化回归检查。 + Status: `done` + Evidence: `tests/unit/test_package_initializers_facade_pattern.py` 新增“公开 facade 禁止直接 `._internal` 导入”规则并通过;`dare_framework/{checkpoint,embedding,event,hook,plan,security,transport}/__init__.py` 已切换为 `defaults` / `impl` / `adapters` 公开导出层;新增 `dare_framework/{checkpoint,embedding,event,hook,plan}/defaults.py` 与 `dare_framework/transport/adapters.py`;`.venv/bin/python -m pytest -q tests/unit/test_package_initializers_facade_pattern.py tests/unit/test_event_sqlite_event_log.py tests/unit/test_security_boundary.py tests/unit/test_embedding_openai_adapter.py tests/unit/test_default_planner.py tests/unit/test_default_remediator.py tests/unit/test_hook_extension_point_governance.py tests/unit/test_transport_channel.py tests/unit/test_execution_control.py` => `52 passed` + Last Updated: `2026-03-04` - [x] T0-5 建立“失败测试 -> 责任模块 -> owner”映射并例行巡检。 Status: `done` Evidence: `scripts/ci/p0_gate.py`(category summary 新增 `owner`);`scripts/ci/check_test_failure_ownership.py`(映射完整性巡检脚本);`.github/workflows/ci-gate.yml`(新增 `failure-ownership-map` job);`docs/guides/P0_Gate_Runbook.md`(命令与排障入口更新);`.venv/bin/python -m pytest -q tests/unit/test_p0_gate_ci.py tests/unit/test_check_test_failure_ownership.py` => `7 passed` diff --git a/tests/unit/test_package_initializers_facade_pattern.py b/tests/unit/test_package_initializers_facade_pattern.py index a7380de2..c51e7e75 100644 --- a/tests/unit/test_package_initializers_facade_pattern.py +++ b/tests/unit/test_package_initializers_facade_pattern.py @@ -13,6 +13,7 @@ def test_package_initializers_follow_facade_pattern() -> None: 3. Assignments allowed only for metadata (e.g., __all__, __version__). 4. Imports allowed for re-exporting. 5. Star imports are prohibited. + 6. Public facades must not import from `._internal` directly. """ repo_root = Path(__file__).resolve().parents[2] package_root = repo_root / "dare_framework" @@ -22,6 +23,7 @@ def test_package_initializers_follow_facade_pattern() -> None: violations: list[str] = [] for path in init_files: source = path.read_text(encoding="utf-8") + is_public_facade = "_internal" not in path.parts if not source.strip(): # Empty files are okay if they are just placeholders, # but the user wanted docstrings. @@ -73,6 +75,25 @@ def test_package_initializers_follow_facade_pattern() -> None: violations.append(f"{path.relative_to(repo_root)}: Prohibited star import in package initializer") continue + # Rule 6: Public facades must not directly import internal modules. + if is_public_facade and isinstance(node, ast.ImportFrom): + module = node.module or "" + if "._internal" in module: + violations.append( + f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({module})" + ) + continue + + if is_public_facade and isinstance(node, ast.Import): + for alias in node.names: + if "._internal" in alias.name: + violations.append( + f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({alias.name})" + ) + break + else: + pass + if isinstance(node, (ast.Import, ast.ImportFrom)): continue From a709b7da6138568b294db60bcb6386a2394ae601 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 5 Mar 2026 10:00:09 +0800 Subject: [PATCH 06/12] Fix facade guard for relative _internal imports The package initializer facade test only matched "._internal" substrings when checking ImportFrom nodes. Relative syntax such as sets node.module to "_internal.foo", which allowed prohibited imports to bypass the guard.\n\nThis update extracts direct internal import detection into helper functions and applies one consistent check for both ImportFrom and Import nodes. It also handles relative module-less forms like so the public facade contract remains enforceable regardless of import style.\n\nAdded a regression test that covers relative and absolute import forms and a non-internal control case to lock the behavior in CI. --- ...est_package_initializers_facade_pattern.py | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_package_initializers_facade_pattern.py b/tests/unit/test_package_initializers_facade_pattern.py index c51e7e75..1bc66235 100644 --- a/tests/unit/test_package_initializers_facade_pattern.py +++ b/tests/unit/test_package_initializers_facade_pattern.py @@ -4,6 +4,42 @@ from pathlib import Path +def _is_internal_module_path(module: str) -> bool: + return module == "_internal" or module.startswith("_internal.") or "._internal" in module + + +def _find_direct_internal_import(node: ast.stmt) -> str | None: + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module and _is_internal_module_path(module): + return f"{'.' * node.level}{module}" + + # `from . import _internal` uses `module is None` and stores names in aliases. + for alias in node.names: + if _is_internal_module_path(alias.name): + return f"{'.' * node.level}{alias.name}" + return None + + if isinstance(node, ast.Import): + for alias in node.names: + if _is_internal_module_path(alias.name): + return alias.name + return None + + +def test_internal_import_detection_covers_relative_syntax() -> None: + cases = { + "from ._internal.foo import Bar": "._internal.foo", + "from .._internal import Foo": ".._internal", + "from . import _internal": "._internal", + "from dare_framework.tool._internal.tools import EchoTool": "dare_framework.tool._internal.tools", + "from dare_framework.tool.defaults import EchoTool": None, + } + for source, expected in cases.items(): + node = ast.parse(source).body[0] + assert _find_direct_internal_import(node) == expected + + def test_package_initializers_follow_facade_pattern() -> None: """Asserts that all dare_framework initializers use the facade pattern. @@ -76,24 +112,14 @@ def test_package_initializers_follow_facade_pattern() -> None: continue # Rule 6: Public facades must not directly import internal modules. - if is_public_facade and isinstance(node, ast.ImportFrom): - module = node.module or "" - if "._internal" in module: + if is_public_facade: + direct_internal_import = _find_direct_internal_import(node) + if direct_internal_import is not None: violations.append( - f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({module})" + f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({direct_internal_import})" ) continue - if is_public_facade and isinstance(node, ast.Import): - for alias in node.names: - if "._internal" in alias.name: - violations.append( - f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({alias.name})" - ) - break - else: - pass - if isinstance(node, (ast.Import, ast.ImportFrom)): continue From ed550e861b9319ad47bdb7a0646c96b2a7ac57c5 Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 5 Mar 2026 16:15:31 +0800 Subject: [PATCH 07/12] feat(model): add standalone Anthropic adapter with direct model pass-through Add a new AnthropicModelAdapter backed by the official anthropic SDK and wire it into the default adapter manager and model exports. Key changes: - implement Anthropic Messages API serialization/parsing for system/user/assistant/tool blocks, tool calls, thinking content, and usage normalization - enforce direct model-name pass-through from Config.llm.model or ANTHROPIC_MODEL (no hard-coded alias mapping), so new Anthropic models do not require framework code changes - extend CLI doctor checks to support anthropic adapter API key and dependency diagnostics - add anthropic dependency to pyproject/requirements - add unit coverage for Anthropic adapter behavior and manager/doctor integration - add OpenSpec change artifacts and feature evidence doc for docs-first governance - update model/client design and usage docs, including a new client/examples/config.anthropic.example.json sample Also include existing workspace cleanup in openrouter_adapter.py (unused imports). --- client/README.md | 28 +- client/commands/info.py | 6 +- client/examples/config.anthropic.example.json | 6 + client/main.py | 2 +- dare_framework/model/__init__.py | 3 +- dare_framework/model/adapters/__init__.py | 3 +- .../model/adapters/anthropic_adapter.py | 391 ++++++++++++++++++ .../model/adapters/openrouter_adapter.py | 4 +- .../model/default_model_adapter_manager.py | 16 +- docs/design/DARE_Formal_Design.md | 2 +- docs/design/modules/model/README.md | 9 +- docs/features/README.md | 1 + docs/features/add-anthropic-model-adapter.md | 86 ++++ .../.openspec.yaml | 2 + .../add-anthropic-model-adapter/design.md | 55 +++ .../add-anthropic-model-adapter/proposal.md | 37 ++ .../specs/anthropic-model-adapter/spec.md | 35 ++ .../add-anthropic-model-adapter/tasks.md | 17 + pyproject.toml | 1 + requirements.txt | 1 + tests/unit/test_anthropic_model_adapter.py | 158 +++++++ tests/unit/test_client_cli.py | 19 + .../test_default_model_adapter_manager.py | 11 +- 23 files changed, 880 insertions(+), 13 deletions(-) create mode 100644 client/examples/config.anthropic.example.json create mode 100644 dare_framework/model/adapters/anthropic_adapter.py create mode 100644 docs/features/add-anthropic-model-adapter.md create mode 100644 openspec/changes/add-anthropic-model-adapter/.openspec.yaml create mode 100644 openspec/changes/add-anthropic-model-adapter/design.md create mode 100644 openspec/changes/add-anthropic-model-adapter/proposal.md create mode 100644 openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md create mode 100644 openspec/changes/add-anthropic-model-adapter/tasks.md create mode 100644 tests/unit/test_anthropic_model_adapter.py diff --git a/client/README.md b/client/README.md index 1a98b0c8..0a8ca02e 100644 --- a/client/README.md +++ b/client/README.md @@ -123,10 +123,10 @@ export OPENROUTER_API_KEY=sk-or-... ### `llm` 字段说明 -- `adapter`:模型适配器,当前支持 `openai` 和 `openrouter`。不写时默认是 `openai`。 +- `adapter`:模型适配器,当前支持 `openai`、`openrouter`、`anthropic`。不写时默认是 `openai`。 - `model`:模型名,例如 `gpt-4o-mini`、`gpt-4.1`、`qwen/qwen3-coder:free`。 - `api_key`:模型服务密钥。也可以通过环境变量提供。 -- `endpoint`:自定义 OpenAI-compatible base URL。对 `openrouter` 来说会作为 `base_url` 使用。 +- `endpoint`:自定义 provider base URL。对 `openrouter`/`anthropic` 来说分别映射到各自 SDK 的 `base_url`。 - `proxy`:代理配置,支持 `http`、`https`、`no_proxy`、`use_system_proxy`、`disabled`。 - 其他未显式声明的字段会进入 `llm.extra`,并透传给 adapter;例如可以直接写 `temperature`、`max_tokens`。 @@ -191,6 +191,29 @@ export OPENROUTER_MODEL=qwen/qwen3-coder:free export OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 ``` +Anthropic: + +```json +{ + "llm": { + "adapter": "anthropic", + "model": "claude-sonnet-4-5" + } +} +``` + +配合环境变量: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +# 建议显式写完整模型名;CLI 会直接透传到 Anthropic SDK +export ANTHROPIC_MODEL=claude-sonnet-4-5 +``` + +仓库示例文件: + +- `client/examples/config.anthropic.example.json` + OpenAI-compatible / 自建模型网关: ```json @@ -294,6 +317,7 @@ OpenAI-compatible / 自建模型网关: - `openai` adapter:需要 `langchain-openai` - `openrouter` adapter:需要 `openai` +- `anthropic` adapter:需要 `anthropic` 否则 `doctor` 会提示 adapter probe 或依赖缺失,runtime 也无法正常启动。 diff --git a/client/commands/info.py b/client/commands/info.py index e1f5e478..9f8de17c 100644 --- a/client/commands/info.py +++ b/client/commands/info.py @@ -52,6 +52,7 @@ def build_doctor_report( api_key_sources = { "openrouter": bool(config.llm.api_key or os.getenv("OPENROUTER_API_KEY")), "openai": bool(config.llm.api_key or os.getenv("OPENAI_API_KEY")), + "anthropic": bool(config.llm.api_key or os.getenv("ANTHROPIC_API_KEY")), } workspace = Path(config.workspace_dir).expanduser().resolve() user_dir = Path(config.user_dir).expanduser().resolve() @@ -72,6 +73,7 @@ def build_doctor_report( "httpx_installed": importlib.util.find_spec("httpx") is not None, "openai_sdk_installed": importlib.util.find_spec("openai") is not None, "langchain_openai_installed": importlib.util.find_spec("langchain_openai") is not None, + "anthropic_sdk_installed": importlib.util.find_spec("anthropic") is not None, } diagnostics: dict[str, Any] = { @@ -95,7 +97,7 @@ def build_doctor_report( warnings: list[str] = [] if not diagnostics["workspace_exists"]: warnings.append("workspace_dir does not exist") - if adapter not in {"openai", "openrouter"}: + if adapter not in {"openai", "openrouter", "anthropic"}: warnings.append(f"unsupported adapter configured: {adapter}") if not diagnostics["llm"]["api_key_present"]: warnings.append(f"missing API key for adapter={adapter}") @@ -111,6 +113,8 @@ def build_doctor_report( warnings.append("openai SDK is required for openrouter adapter") if adapter == "openai" and not deps["langchain_openai_installed"]: warnings.append("langchain-openai is required for openai adapter") + if adapter == "anthropic" and not deps["anthropic_sdk_installed"]: + warnings.append("anthropic SDK is required for anthropic adapter") diagnostics["warnings"] = warnings diagnostics["ok"] = len(warnings) == 0 return diagnostics diff --git a/client/examples/config.anthropic.example.json b/client/examples/config.anthropic.example.json new file mode 100644 index 00000000..62ca1f8a --- /dev/null +++ b/client/examples/config.anthropic.example.json @@ -0,0 +1,6 @@ +{ + "llm": { + "adapter": "anthropic", + "model": "claude-sonnet-4-5" + } +} diff --git a/client/main.py b/client/main.py index 5a1d9a38..fa50de49 100644 --- a/client/main.py +++ b/client/main.py @@ -1300,7 +1300,7 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="DARE external CLI") parser.add_argument("--workspace", default=str(Path.cwd()), help="workspace root path") parser.add_argument("--user-dir", default=str(Path.home()), help="user directory path") - parser.add_argument("--adapter", default=None, help="llm adapter override (openai/openrouter)") + parser.add_argument("--adapter", default=None, help="llm adapter override (openai/openrouter/anthropic)") parser.add_argument("--model", default=None, help="llm model override") parser.add_argument("--api-key", default=None, help="llm api key override") parser.add_argument("--endpoint", default=None, help="llm endpoint override") diff --git a/dare_framework/model/__init__.py b/dare_framework/model/__init__.py index 3afc7a8c..45e2c1da 100644 --- a/dare_framework/model/__init__.py +++ b/dare_framework/model/__init__.py @@ -9,7 +9,7 @@ from dare_framework.model.builtin_prompt_loader import BuiltInPromptLoader from dare_framework.model.filesystem_prompt_loader import FileSystemPromptLoader from dare_framework.model.layered_prompt_store import LayeredPromptStore -from dare_framework.model.adapters import OpenAIModelAdapter, OpenRouterModelAdapter +from dare_framework.model.adapters import AnthropicModelAdapter, OpenAIModelAdapter, OpenRouterModelAdapter __all__ = [ "IModelAdapter", @@ -24,6 +24,7 @@ "BuiltInPromptLoader", "FileSystemPromptLoader", "LayeredPromptStore", + "AnthropicModelAdapter", "OpenAIModelAdapter", "OpenRouterModelAdapter", ] diff --git a/dare_framework/model/adapters/__init__.py b/dare_framework/model/adapters/__init__.py index b7bfd029..e0662803 100644 --- a/dare_framework/model/adapters/__init__.py +++ b/dare_framework/model/adapters/__init__.py @@ -1,6 +1,7 @@ """Model adapters.""" +from dare_framework.model.adapters.anthropic_adapter import AnthropicModelAdapter from dare_framework.model.adapters.openai_adapter import OpenAIModelAdapter from dare_framework.model.adapters.openrouter_adapter import OpenRouterModelAdapter -__all__ = ["OpenAIModelAdapter", "OpenRouterModelAdapter"] +__all__ = ["AnthropicModelAdapter", "OpenAIModelAdapter", "OpenRouterModelAdapter"] diff --git a/dare_framework/model/adapters/anthropic_adapter.py b/dare_framework/model/adapters/anthropic_adapter.py new file mode 100644 index 00000000..30384608 --- /dev/null +++ b/dare_framework/model/adapters/anthropic_adapter.py @@ -0,0 +1,391 @@ +"""Anthropic model adapter using the official anthropic SDK.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from dare_framework.model.kernel import IModelAdapter +from dare_framework.model.types import GenerateOptions, ModelInput, ModelResponse + + +class AnthropicModelAdapter(IModelAdapter): + """Model adapter for Anthropic Messages API.""" + + def __init__( + self, + *, + name: str | None = None, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + http_client_options: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, + ) -> None: + self._name = name or "anthropic" + self._api_key = api_key or os.getenv("ANTHROPIC_API_KEY") + self._model = _resolve_model_name(model=model, env_model=os.getenv("ANTHROPIC_MODEL")) + self._base_url = base_url or os.getenv("ANTHROPIC_BASE_URL") + self._http_client_options = dict(http_client_options or {}) + self._extra = dict(extra or {}) + self._client: Any = None + + if not self._api_key: + raise ValueError("Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable.") + + @property + def name(self) -> str: + return self._name + + @property + def model(self) -> str: + return self._model + + @property + def model_name(self) -> str: + return self._model + + async def generate( + self, + model_input: ModelInput, + *, + options: GenerateOptions | None = None, + ) -> ModelResponse: + client = self._ensure_client() + system_prompt, messages = _serialize_system_and_messages(model_input.messages) + + params: dict[str, Any] = { + "model": self._model, + "messages": messages, + "max_tokens": _resolve_max_tokens(options=options, extra=self._extra), + } + if system_prompt: + params["system"] = system_prompt + if model_input.tools: + params["tools"] = [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema, + } + for tool in model_input.tools + ] + + if self._extra: + params.update(self._extra) + if options is not None: + if options.temperature is not None: + params["temperature"] = options.temperature + if options.max_tokens is not None: + params["max_tokens"] = options.max_tokens + if options.top_p is not None: + params["top_p"] = options.top_p + if options.stop is not None: + params["stop_sequences"] = options.stop + + response = await client.messages.create(**params) + content_blocks = list(getattr(response, "content", []) or []) + + return ModelResponse( + content=_extract_response_text(content_blocks), + tool_calls=_extract_tool_calls(content_blocks), + usage=_extract_usage(getattr(response, "usage", None)), + thinking_content=_extract_thinking_content(content_blocks), + metadata={ + "model": self._model, + "stop_reason": getattr(response, "stop_reason", None), + }, + ) + + def _ensure_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + try: + from anthropic import AsyncAnthropic + except ImportError as exc: + raise ImportError( + "anthropic SDK is required for AnthropicModelAdapter. Install with: pip install anthropic" + ) from exc + + client_kwargs: dict[str, Any] = {"api_key": self._api_key} + if self._base_url: + client_kwargs["base_url"] = self._base_url + + http_client = _build_async_http_client(self._http_client_options) + if http_client is not None: + client_kwargs["http_client"] = http_client + + return AsyncAnthropic(**client_kwargs) + + +def _resolve_model_name(*, model: str | None, env_model: str | None) -> str: + selected = model or env_model + if not selected or not selected.strip(): + raise ValueError( + "Anthropic model is required. Set Config.llm.model or ANTHROPIC_MODEL environment variable." + ) + return selected.strip() + + +def _resolve_max_tokens(*, options: GenerateOptions | None, extra: dict[str, Any]) -> int: + if options is not None and options.max_tokens is not None: + return int(options.max_tokens) + extra_value = extra.get("max_tokens") + if extra_value is not None: + return int(extra_value) + env_value = os.getenv("ANTHROPIC_MAX_TOKENS") + if env_value is not None: + try: + return int(env_value) + except ValueError: + pass + return 2048 + + +def _serialize_system_and_messages(messages: list[Any]) -> tuple[str | None, list[dict[str, Any]]]: + system_parts: list[str] = [] + payload: list[dict[str, Any]] = [] + + for msg in messages: + role = str(getattr(msg, "role", "user")) + content = str(getattr(msg, "content", "")) + if role == "system": + text = content.strip() + if text: + system_parts.append(text) + continue + + if role == "assistant": + message_content: list[dict[str, Any]] = [] + if content: + message_content.append({"type": "text", "text": content}) + tool_calls = _normalize_tool_calls(getattr(msg, "metadata", {}).get("tool_calls", [])) + message_content.extend(tool_calls) + if not message_content: + message_content.append({"type": "text", "text": ""}) + payload.append({"role": "assistant", "content": message_content}) + continue + + if role == "tool": + tool_call_id = getattr(msg, "name", None) or "tool_call" + payload.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": content, + } + ], + } + ) + continue + + payload.append({"role": "user", "content": content}) + + system_prompt = "\n\n".join(system_parts) if system_parts else None + return system_prompt, payload + + +def _normalize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]: + if not isinstance(tool_calls, list): + return [] + + normalized: list[dict[str, Any]] = [] + for index, call in enumerate(tool_calls): + if not isinstance(call, dict): + continue + call_id = call.get("id") or call.get("tool_call_id") or f"toolu_{index + 1}" + name = call.get("name") + if not isinstance(name, str) or not name.strip(): + continue + arguments = call.get("arguments", call.get("args", {})) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {"raw": arguments} + if not isinstance(arguments, dict): + arguments = {} + normalized.append( + { + "type": "tool_use", + "id": str(call_id), + "name": name, + "input": arguments, + } + ) + return normalized + + +def _extract_response_text(content_blocks: list[Any]) -> str: + parts: list[str] = [] + for block in content_blocks: + if _block_type(block) != "text": + continue + text = _coerce_text(_block_value(block, "text")) + if text: + parts.append(text) + return "\n".join(parts) + + +def _extract_thinking_content(content_blocks: list[Any]) -> str | None: + parts: list[str] = [] + for block in content_blocks: + if _block_type(block) not in {"thinking", "reasoning"}: + continue + text = ( + _coerce_text(_block_value(block, "thinking")) + or _coerce_text(_block_value(block, "reasoning")) + or _coerce_text(_block_value(block, "text")) + or _coerce_text(_block_value(block, "content")) + ) + if text: + parts.append(text) + if not parts: + return None + return "\n".join(parts) + + +def _extract_tool_calls(content_blocks: list[Any]) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + for block in content_blocks: + if _block_type(block) != "tool_use": + continue + call_id = _block_value(block, "id") + name = _block_value(block, "name") + if not isinstance(name, str) or not name.strip(): + continue + arguments = _block_value(block, "input") + if not isinstance(arguments, dict): + arguments = {} + calls.append( + { + "id": str(call_id) if call_id is not None else None, + "name": name, + "arguments": arguments, + } + ) + return calls + + +def _extract_usage(usage: Any) -> dict[str, Any] | None: + if usage is None: + return None + + prompt_tokens = _to_int(_get_nested_value(usage, "input_tokens")) + completion_tokens = _to_int(_get_nested_value(usage, "output_tokens")) + total_tokens = ( + prompt_tokens + completion_tokens + if prompt_tokens is not None and completion_tokens is not None + else _to_int(_get_nested_value(usage, "total_tokens")) + ) + + payload: dict[str, Any] = {} + if prompt_tokens is not None: + payload["prompt_tokens"] = prompt_tokens + if completion_tokens is not None: + payload["completion_tokens"] = completion_tokens + if total_tokens is not None: + payload["total_tokens"] = total_tokens + + reasoning_tokens = _extract_reasoning_tokens(usage) + if reasoning_tokens is not None: + payload["reasoning_tokens"] = reasoning_tokens + + if not payload: + return None + return payload + + +def _extract_reasoning_tokens(usage: Any) -> int | None: + candidates = [ + _get_nested_value(usage, "reasoning_tokens"), + _get_nested_value(_get_nested_value(usage, "output_tokens_details"), "reasoning_tokens"), + _get_nested_value(_get_nested_value(usage, "output_tokens_details"), "thinking_tokens"), + _get_nested_value(_get_nested_value(usage, "completion_tokens_details"), "reasoning_tokens"), + ] + for candidate in candidates: + value = _to_int(candidate) + if value is not None: + return value + return None + + +def _block_type(block: Any) -> str | None: + if isinstance(block, dict): + value = block.get("type") + else: + value = getattr(block, "type", None) + return str(value) if value is not None else None + + +def _block_value(block: Any, key: str) -> Any: + if isinstance(block, dict): + return block.get(key) + return getattr(block, key, None) + + +def _get_nested_value(value: Any, key: str) -> Any: + if isinstance(value, dict): + return value.get(key) + return getattr(value, key, None) + + +def _to_int(value: Any) -> int | None: + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_text(value: Any) -> str | None: + if isinstance(value, str): + text = value.strip() + return text or None + if isinstance(value, dict): + for key in ("text", "content", "thinking", "reasoning"): + text = _coerce_text(value.get(key)) + if text: + return text + return None + if isinstance(value, list): + parts: list[str] = [] + for item in value: + text = _coerce_text(item) + if text: + parts.append(text) + if parts: + return "\n".join(parts) + return None + + +def _build_async_http_client(options: dict[str, Any]) -> Any | None: + if not options: + return None + try: + import httpx + except Exception: + return None + try: + return httpx.AsyncClient(**options) + except Exception: + return None + + +__all__ = [ + "AnthropicModelAdapter", + "_extract_response_text", + "_extract_thinking_content", + "_extract_tool_calls", + "_extract_usage", + "_resolve_model_name", + "_serialize_system_and_messages", +] diff --git a/dare_framework/model/adapters/openrouter_adapter.py b/dare_framework/model/adapters/openrouter_adapter.py index 80ceb20e..1177efb2 100644 --- a/dare_framework/model/adapters/openrouter_adapter.py +++ b/dare_framework/model/adapters/openrouter_adapter.py @@ -4,12 +4,10 @@ import json import os -from typing import Any, Literal +from typing import Any -from dare_framework.infra.component import ComponentType from dare_framework.model.kernel import IModelAdapter from dare_framework.model.types import GenerateOptions, ModelInput, ModelResponse -from dare_framework.tool.types import CapabilityDescriptor class OpenRouterModelAdapter(IModelAdapter): diff --git a/dare_framework/model/default_model_adapter_manager.py b/dare_framework/model/default_model_adapter_manager.py index 247417ee..71f2b09b 100644 --- a/dare_framework/model/default_model_adapter_manager.py +++ b/dare_framework/model/default_model_adapter_manager.py @@ -7,6 +7,7 @@ from dare_framework.config.types import Config, LLMConfig from dare_framework.model.interfaces import IModelAdapterManager from dare_framework.model.kernel import IModelAdapter +from dare_framework.model.adapters.anthropic_adapter import AnthropicModelAdapter from dare_framework.model.adapters.openai_adapter import OpenAIModelAdapter from dare_framework.model.adapters.openrouter_adapter import OpenRouterModelAdapter @@ -27,8 +28,10 @@ def load_model_adapter(self, *, config: Config | None = None) -> IModelAdapter | return _build_openai_adapter(llm) if adapter_name == "openrouter": return _build_openrouter_adapter(llm) + if adapter_name == "anthropic": + return _build_anthropic_adapter(llm) raise ValueError( - f"Unsupported model adapter '{adapter_name}'. Supported adapters: openai, openrouter." + f"Unsupported model adapter '{adapter_name}'. Supported adapters: openai, openrouter, anthropic." ) @@ -61,6 +64,17 @@ def _build_openrouter_adapter(llm: LLMConfig) -> OpenRouterModelAdapter: ) +def _build_anthropic_adapter(llm: LLMConfig) -> AnthropicModelAdapter: + return AnthropicModelAdapter( + name="anthropic", + api_key=llm.api_key, + model=llm.model, + base_url=llm.endpoint, + http_client_options=_http_client_options_from_proxy(llm), + extra=dict(llm.extra), + ) + + def _http_client_options_from_proxy(llm: LLMConfig) -> dict[str, Any]: proxy = llm.proxy options: dict[str, Any] = {} diff --git a/docs/design/DARE_Formal_Design.md b/docs/design/DARE_Formal_Design.md index 1e40ca6f..69be4b96 100644 --- a/docs/design/DARE_Formal_Design.md +++ b/docs/design/DARE_Formal_Design.md @@ -383,7 +383,7 @@ flowchart LR - **职责**:统一模型调用入口 + Prompt 管理。 - **关键类型**:`ModelInput` / `ModelResponse` / `Prompt`。 - **核心接口**:`IModelAdapter` / `IModelAdapterManager` / `IPromptStore`。 -- **默认实现**:OpenAI/OpenRouter 适配器 + LayeredPromptStore。 +- **默认实现**:OpenAI/OpenRouter/Anthropic 适配器 + LayeredPromptStore。 - **扩展点**:多模型路由、流式输出、Prompt Loader。 - **现状限制**:无流式输出与多阶段 prompt pack。 diff --git a/docs/design/modules/model/README.md b/docs/design/modules/model/README.md index babf0707..79889b55 100644 --- a/docs/design/modules/model/README.md +++ b/docs/design/modules/model/README.md @@ -1,6 +1,6 @@ # Module: model -> Status: detailed design aligned to `dare_framework/model` (2026-02-25). +> Status: detailed design aligned to `dare_framework/model` (2026-03-05). ## 1. 定位与职责 @@ -58,6 +58,12 @@ flowchart TD - **Config**:`Config.llm` 决定 adapter 类型与连接参数。 - **Observability**:从 `usage` 提取 token 指标。 +## 6.1 默认 Adapter 能力矩阵 + +- `openai`: 基于 `langchain-openai`,适配 OpenAI-compatible Chat 接口。 +- `openrouter`: 基于 `openai` SDK,适配 OpenRouter OpenAI-compatible 接口。 +- `anthropic`: 基于 `anthropic` 官方 SDK,适配 Anthropic Messages API(模型名透传 + tool blocks)。 + ## 7. 约束与限制 - 当前流式输出和增量 tool-call 仍是待补齐项。 @@ -91,3 +97,4 @@ flowchart TD - `tests/unit/test_default_model_adapter_manager.py`(模型适配器管理与选择) - `tests/unit/test_openrouter_adapter.py`(adapter 消息序列化与 tool-call 兼容) +- `tests/unit/test_anthropic_model_adapter.py`(Anthropic adapter 请求/响应规范化) diff --git a/docs/features/README.md b/docs/features/README.md index cb33119a..b8cd1c3a 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -37,6 +37,7 @@ ## Active Entries +- `docs/features/add-anthropic-model-adapter.md` - `docs/features/agentscope-d2-d4-thinking-transport.md` - `docs/features/agentscope-d5-safe-compression.md` - `docs/features/agentscope-d7-plan-state-tools.md` diff --git a/docs/features/add-anthropic-model-adapter.md b/docs/features/add-anthropic-model-adapter.md new file mode 100644 index 00000000..c1c7db63 --- /dev/null +++ b/docs/features/add-anthropic-model-adapter.md @@ -0,0 +1,86 @@ +--- +change_ids: ["add-anthropic-model-adapter"] +doc_kind: feature +topics: ["model", "anthropic", "adapter", "cli-doctor"] +created: 2026-03-05 +updated: 2026-03-05 +status: draft +mode: openspec +--- + +# Feature: add-anthropic-model-adapter + +## Scope +新增独立 `AnthropicModelAdapter`,对接 Anthropic 官方 Messages API,并在 runtime/CLI 侧完成 `anthropic` adapter 的加载与诊断接入,不改造既有 OpenAI/OpenRouter adapter 内部实现。 + +## OpenSpec Artifacts +- Proposal: `openspec/changes/add-anthropic-model-adapter/proposal.md` +- Design: `openspec/changes/add-anthropic-model-adapter/design.md` +- Specs: + - `openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md` +- Tasks: `openspec/changes/add-anthropic-model-adapter/tasks.md` + +## Progress +- 已完成:Anthropic adapter 代码、manager 接入、CLI doctor 接入、依赖与文档更新。 +- 已完成:定向单测回归通过。 +- 待完成:评审链接与归档动作。 + +## Evidence + +### External References +- Anthropic Messages API: `https://docs.anthropic.com/en/api/messages` +- Anthropic tool use examples: `https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview` +- OpenRouter Anthropic Opus model page: `https://openrouter.ai/anthropic/claude-opus-4.1` +- OpenRouter Anthropic Sonnet model page: `https://openrouter.ai/anthropic/claude-sonnet-4.5` + +### Commands +- `openspec new change "add-anthropic-model-adapter"` +- `openspec status --change "add-anthropic-model-adapter" --json` +- `.venv/bin/pytest -q tests/unit/test_anthropic_model_adapter.py tests/unit/test_default_model_adapter_manager.py tests/unit/test_client_cli.py` + +### Results +- OpenSpec change 创建成功(schema: `spec-driven`)。 +- Anthropic adapter 目标测试集通过:`58 passed, 1 warning`。 + +### Contract Delta +- schema: `changed`(新增 `anthropic` adapter 选择与请求/响应规范化行为,模型名为配置直传)。 +- error semantics: `changed`(`anthropic` SDK 缺失时新增明确错误提示)。 +- retry: `none`(未新增重试语义)。 + +### Golden Cases +- `tests/unit/test_anthropic_model_adapter.py` +- `tests/unit/test_default_model_adapter_manager.py` +- `tests/unit/test_client_cli.py` + +### Regression Summary +- 定向回归通过,覆盖新增 adapter 的序列化、模型名直传、manager 选择与 CLI doctor 诊断分支。 + +### Observability and Failure Localization +- 入口:`DefaultModelAdapterManager.load_model_adapter(config)`。 +- tool_call/tool_result 序列化:`AnthropicModelAdapter._serialize_system_and_messages`。 +- 响应解析:`_extract_response_text` / `_extract_tool_calls` / `_extract_usage`。 +- 失败定位:`_build_client`(依赖缺失)、`build_doctor_report`(依赖与 API key 诊断)。 + +### Structured Review Report +- module boundary: 仅新增 Anthropic adapter 模块并修改最小接入面(manager/export/doctor)。 +- state: 无新增全局可变状态,client 延续 lazy client 初始化。 +- concurrency: 复用 async client 调用模型 API,不引入并发共享写路径。 +- side-effect: 新增外部依赖 `anthropic`,其余 side-effect 不变。 +- coverage: 新增/修改行为均有对应单测。 + +### Behavior Verification +- Happy path: + - `adapter=anthropic` 时可加载 Anthropic adapter 并完成消息/工具块序列化与响应归一化。 + - 显式模型名(例如 `claude-sonnet-4-5`)会原样透传到 Anthropic SDK。 +- Error branch: + - 缺少 `ANTHROPIC_API_KEY` 或 `api_key` 时抛出显式错误。 + - 缺少 `Config.llm.model` 且 `ANTHROPIC_MODEL` 未设置时抛出模型必填错误。 + - doctor 在 `adapter=anthropic` 且未安装 `anthropic` SDK 时输出依赖告警。 + +### Risks and Rollback +- 风险:运行配置缺少模型名会导致 adapter 初始化失败。 +- 回滚:删除 `anthropic` 分支与新增 adapter 文件即可恢复至原行为,不影响 OpenAI/OpenRouter 路径。 + +### Review and Merge Gate Links +- Review request: 待创建 PR 后补充。 +- Merge gate: 待 CI 与 reviewer 通过后补充。 diff --git a/openspec/changes/add-anthropic-model-adapter/.openspec.yaml b/openspec/changes/add-anthropic-model-adapter/.openspec.yaml new file mode 100644 index 00000000..8f0b8699 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-05 diff --git a/openspec/changes/add-anthropic-model-adapter/design.md b/openspec/changes/add-anthropic-model-adapter/design.md new file mode 100644 index 00000000..68dc0550 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/design.md @@ -0,0 +1,55 @@ +## Context + +现有 model domain 的默认适配器仅覆盖 OpenAI / OpenRouter。用户需要“完整新增”Anthropic adapter,而不是在已有 adapter 上做兼容分支。该改动涉及模型调用层、默认 adapter 管理器、CLI 诊断链路和文档契约更新,属于跨模块变更。 + +## Goals / Non-Goals + +**Goals:** +- 提供独立 `AnthropicModelAdapter`,并保持与现有 `IModelAdapter` 契约一致。 +- 模型名仅做直传:由 `Config.llm.model` 或 `ANTHROPIC_MODEL` 提供,不维护硬编码别名映射。 +- 支持 tool calling 对话回放链路:assistant `tool_use` 与 tool `tool_result`。 +- 在 CLI doctor 中加入 `anthropic` adapter 的配置与依赖可观测性。 + +**Non-Goals:** +- 不改造 OpenAI / OpenRouter adapter 的内部逻辑。 +- 不实现 streaming 接口。 +- 不新增多 provider 自动路由策略。 + +## Decisions + +1. **独立实现 Anthropic adapter** +- Decision: 新建 `dare_framework/model/adapters/anthropic_adapter.py`,不复用 `openrouter_adapter.py`。 +- Rationale: 用户要求“完整新增”;Anthropic Messages API 的 message/tool 结构也与 OpenAI-compatible 接口不同。 +- Alternative considered: 在 OpenRouter adapter 内新增 Anthropic 分支,违背隔离要求且会放大耦合。 + +2. **模型名来源固定为配置/环境变量** +- Decision: 适配器不做模型别名转换,仅接受 `Config.llm.model` 或 `ANTHROPIC_MODEL` 的直接值。 +- Rationale: 避免“新模型发布 -> 需要改框架代码”耦合,模型升级由配置侧完成。 +- Alternative considered: 内置别名映射;该方案会引入版本漂移与维护负担。 + +3. **序列化策略对齐 Anthropic Messages API** +- Decision: `system` 消息单独抽取到 `system` 字段;assistant 历史 tool call 序列化为 `tool_use` block;tool 回包序列化为 user `tool_result` block。 +- Rationale: 与 Anthropic 官方 tool-use 协议一致,保证多轮工具调用可追踪。 + +4. **CLI doctor 扩展 anthropic 诊断** +- Decision: 新增 `ANTHROPIC_API_KEY` 来源检查和 `anthropic` SDK 依赖检查。 +- Rationale: 让运行前诊断与新增 adapter 保持一致,避免误判“unsupported adapter”。 + +## Risks / Trade-offs + +- [Risk] Anthropic SDK 未安装导致运行期失败。 + Mitigation: doctor 增加依赖告警;adapter 在 `_build_client` 处抛出明确 ImportError。 +- [Risk] Anthropic API block 字段未来变化导致解析漂移。 + Mitigation: 解析函数统一封装 `_extract_*`,并以单元测试锚定。 +- [Risk] 用户未配置模型名会导致启动失败。 + Mitigation: 在 adapter 初始化阶段给出明确错误信息;README 与示例文件提供最小配置模板。 + +## Migration Plan + +1. 添加 Anthropic adapter 与单测(先红后绿)。 +2. 接入 manager/export/doctor 并补齐相关测试。 +3. 更新设计文档与 client 使用文档。 +4. 运行定向测试验证新增能力。 + +Rollback: +- 若出现兼容问题,可仅回退 `default_model_adapter_manager.py` 中 `anthropic` 分支和新增 adapter 文件,不影响既有 `openai/openrouter` 路径。 diff --git a/openspec/changes/add-anthropic-model-adapter/proposal.md b/openspec/changes/add-anthropic-model-adapter/proposal.md new file mode 100644 index 00000000..eaf10429 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/proposal.md @@ -0,0 +1,37 @@ +## Why + +当前框架仅内置 `openai` / `openrouter` 两类模型适配器,无法直接对接 Anthropic 官方 Messages API。用户明确需要独立 Anthropic adapter,并要求模型名由用户配置直传,避免因新模型发布而改代码。 + +## What Changes + +- 新增独立 `AnthropicModelAdapter`,基于 Anthropic 官方 Python SDK 的 `messages.create` 接口实现。 +- 在 adapter 内统一处理消息序列化(含 tool history / tool result)、响应反序列化(text/tool_use/thinking/usage)。 +- 在 `DefaultModelAdapterManager` 增加 `anthropic` 分支,支持通过 `Config.llm.adapter="anthropic"` 加载。 +- 在 CLI doctor 诊断中增加 `anthropic` adapter 的 API Key 与依赖检查。 +- 更新 model/client 设计与使用文档,补充 Anthropic 配置与官方参考链接。 + +## Capabilities + +### New Capabilities +- `anthropic-model-adapter`: Anthropic 官方 API 的模型适配能力(模型名直传,tool calling,usage/thinking 归一化)。 + +### Modified Capabilities +- None. + +## Impact + +- Affected code: + - `dare_framework/model/adapters/anthropic_adapter.py` + - `dare_framework/model/adapters/__init__.py` + - `dare_framework/model/__init__.py` + - `dare_framework/model/default_model_adapter_manager.py` + - `client/commands/info.py` + - `client/main.py` + - `pyproject.toml` + - `requirements.txt` +- Affected tests: + - `tests/unit/test_anthropic_model_adapter.py` + - `tests/unit/test_default_model_adapter_manager.py` + - `tests/unit/test_client_cli.py` +- New dependency: + - `anthropic` (official SDK) diff --git a/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md b/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md new file mode 100644 index 00000000..93e347c7 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Anthropic adapter uses direct model-name pass-through +The model domain SHALL provide an Anthropic adapter that can be selected via `Config.llm.adapter="anthropic"` and MUST accept model names as direct pass-through values from `Config.llm.model` or `ANTHROPIC_MODEL`. + +#### Scenario: Adapter uses explicit model from config +- **WHEN** config provides model `claude-sonnet-4-5` +- **THEN** the adapter request uses `claude-sonnet-4-5` as-is + +#### Scenario: Adapter requires model source when unset +- **WHEN** both `Config.llm.model` and `ANTHROPIC_MODEL` are missing +- **THEN** adapter initialization fails with an explicit model-required error + +### Requirement: Anthropic adapter serializes tool history using Messages API blocks +The Anthropic adapter SHALL serialize assistant tool-call history and tool results according to Anthropic Messages API content blocks. + +#### Scenario: Assistant history contains tool calls +- **WHEN** assistant history metadata includes tool calls +- **THEN** the adapter emits `tool_use` blocks with stable ids, names, and JSON object inputs + +#### Scenario: Tool result is appended to history +- **WHEN** a tool result message is present in history +- **THEN** the adapter emits a user `tool_result` block that references the originating `tool_use_id` + +### Requirement: Anthropic adapter normalizes response content and usage +The Anthropic adapter SHALL normalize response text, tool calls, thinking content, and usage fields into `ModelResponse`. + +#### Scenario: Response includes text and tool_use blocks +- **WHEN** Anthropic response content contains `text` and `tool_use` blocks +- **THEN** `ModelResponse.content` contains the text content +- **AND** `ModelResponse.tool_calls` contains normalized tool calls + +#### Scenario: Response includes usage counters +- **WHEN** Anthropic response usage provides input/output token counters +- **THEN** `ModelResponse.usage` includes `prompt_tokens`, `completion_tokens`, and `total_tokens` diff --git a/openspec/changes/add-anthropic-model-adapter/tasks.md b/openspec/changes/add-anthropic-model-adapter/tasks.md new file mode 100644 index 00000000..d0480c13 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/tasks.md @@ -0,0 +1,17 @@ +## 1. Anthropic adapter implementation + +- [x] 1.1 Add `AnthropicModelAdapter` with Anthropic Messages API request/response normalization. +- [x] 1.2 Enforce model-name pass-through from config/env without hard-coded alias mapping. +- [x] 1.3 Add adapter-focused unit tests for serialization, parsing, and generate-path payload building. + +## 2. Runtime and CLI integration + +- [x] 2.1 Wire `anthropic` into `DefaultModelAdapterManager` and model facade exports. +- [x] 2.2 Extend CLI doctor diagnostics for `anthropic` adapter key/dependency checks. +- [x] 2.3 Update unit tests covering manager selection and doctor behavior. + +## 3. Documentation and dependency sync + +- [x] 3.1 Update model/client documentation to include Anthropic adapter usage and constraints. +- [x] 3.2 Add governance feature/evidence document for this change. +- [x] 3.3 Update dependency manifests to include Anthropic SDK. diff --git a/pyproject.toml b/pyproject.toml index 6c9fc37f..e2f361ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ version = "0.1.0" description = "DARE Framework and external CLI runtime" requires-python = ">=3.12" dependencies = [ + "anthropic", "langchain-openai", "langchain-core", "httpx>=0.27.0", diff --git a/requirements.txt b/requirements.txt index b7b8c67c..beed5456 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +anthropic langchain-openai langchain-core pytest diff --git a/tests/unit/test_anthropic_model_adapter.py b/tests/unit/test_anthropic_model_adapter.py new file mode 100644 index 00000000..7ab04e8c --- /dev/null +++ b/tests/unit/test_anthropic_model_adapter.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from dare_framework.context.types import Message +from dare_framework.model.adapters.anthropic_adapter import ( + AnthropicModelAdapter, + _extract_response_text, + _extract_thinking_content, + _extract_tool_calls, + _extract_usage, + _resolve_model_name, + _serialize_system_and_messages, +) +from dare_framework.model.types import GenerateOptions, ModelInput +from dare_framework.tool.types import CapabilityDescriptor, CapabilityType + + +def test_resolve_model_name_prefers_explicit_model_value() -> None: + assert _resolve_model_name(model="claude-sonnet-4-5", env_model="claude-opus-4-1") == "claude-sonnet-4-5" + + +def test_resolve_model_name_uses_env_model_when_explicit_missing() -> None: + assert _resolve_model_name(model=None, env_model="claude-opus-4-1") == "claude-opus-4-1" + + +def test_resolve_model_name_requires_model_source() -> None: + with pytest.raises(ValueError, match="Anthropic model is required"): + _resolve_model_name(model=None, env_model=None) + + +def test_serialize_system_and_messages_preserves_tool_history() -> None: + system_prompt, payload_messages = _serialize_system_and_messages( + [ + Message(role="system", content="You are a helpful assistant."), + Message(role="user", content="Need a filename"), + Message( + role="assistant", + content="I need your confirmation first.", + metadata={ + "tool_calls": [ + { + "id": "call_1", + "name": "ask_user", + "arguments": {"question": "Pick a file name"}, + } + ] + }, + ), + Message( + role="tool", + name="call_1", + content="a.txt", + ), + ] + ) + + assert system_prompt == "You are a helpful assistant." + assert payload_messages[0] == {"role": "user", "content": "Need a filename"} + assert payload_messages[1]["role"] == "assistant" + assert payload_messages[1]["content"][0]["type"] == "text" + assert payload_messages[1]["content"][1] == { + "type": "tool_use", + "id": "call_1", + "name": "ask_user", + "input": {"question": "Pick a file name"}, + } + assert payload_messages[2] == { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "a.txt"}], + } + + +def test_extract_response_fields_from_content_blocks() -> None: + blocks = [ + SimpleNamespace(type="thinking", thinking="internal reasoning"), + SimpleNamespace(type="text", text="final answer"), + SimpleNamespace(type="tool_use", id="toolu_1", name="search", input={"q": "hi"}), + ] + usage = SimpleNamespace( + input_tokens=12, + output_tokens=8, + output_tokens_details=SimpleNamespace(reasoning_tokens=3), + ) + + assert _extract_response_text(blocks) == "final answer" + assert _extract_thinking_content(blocks) == "internal reasoning" + assert _extract_tool_calls(blocks) == [{"id": "toolu_1", "name": "search", "arguments": {"q": "hi"}}] + assert _extract_usage(usage) == { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20, + "reasoning_tokens": 3, + } + + +@pytest.mark.asyncio +async def test_generate_builds_anthropic_payload_and_parses_response() -> None: + calls: list[dict[str, object]] = [] + + class _MessagesAPI: + async def create(self, **kwargs): # noqa: ANN003 + calls.append(kwargs) + return SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking="plan"), + SimpleNamespace(type="tool_use", id="toolu_1", name="search", input={"q": "docs"}), + SimpleNamespace(type="text", text="done"), + ], + usage=SimpleNamespace(input_tokens=7, output_tokens=5), + stop_reason="end_turn", + ) + + class _FakeClient: + def __init__(self) -> None: + self.messages = _MessagesAPI() + + adapter = AnthropicModelAdapter(api_key="test-key", model="claude-opus-4-1") + adapter._client = _FakeClient() # type: ignore[assignment] + + result = await adapter.generate( + ModelInput( + messages=[Message(role="system", content="You are careful."), Message(role="user", content="hello")], + tools=[ + CapabilityDescriptor( + id="search-docs", + type=CapabilityType.TOOL, + name="search", + description="Search docs", + input_schema={"type": "object", "properties": {"q": {"type": "string"}}}, + output_schema={"type": "object"}, + metadata={}, + ) + ], + ), + options=GenerateOptions(max_tokens=128, stop=["STOP"]), + ) + + assert calls + params = calls[0] + assert params["model"] == "claude-opus-4-1" + assert params["system"] == "You are careful." + assert params["stop_sequences"] == ["STOP"] + assert params["max_tokens"] == 128 + assert params["tools"] == [ + { + "name": "search", + "description": "Search docs", + "input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ] + + assert result.content == "done" + assert result.thinking_content == "plan" + assert result.tool_calls == [{"id": "toolu_1", "name": "search", "arguments": {"q": "docs"}}] + assert result.usage == {"prompt_tokens": 7, "completion_tokens": 5, "total_tokens": 12} diff --git a/tests/unit/test_client_cli.py b/tests/unit/test_client_cli.py index 2714c94b..fb40bb35 100644 --- a/tests/unit/test_client_cli.py +++ b/tests/unit/test_client_cli.py @@ -244,6 +244,25 @@ def test_build_doctor_report_accepts_openai_with_key() -> None: assert "workspace_dir" in payload +def test_build_doctor_report_accepts_anthropic_with_key() -> None: + config = Config.from_dict( + { + "workspace_dir": ".", + "user_dir": ".", + "llm": { + "adapter": "anthropic", + "model": "claude-sonnet-4-5", + "api_key": "dummy", + }, + "mcp_paths": [], + } + ) + payload = build_doctor_report(config=config) + assert payload["llm"]["adapter"] == "anthropic" + assert payload["llm"]["api_key_present"] is True + assert not any("unsupported adapter configured" in item for item in payload["warnings"]) + + @pytest.mark.asyncio async def test_main_doctor_does_not_bootstrap_runtime(monkeypatch, tmp_path) -> None: client_main = importlib.import_module("client.main") diff --git a/tests/unit/test_default_model_adapter_manager.py b/tests/unit/test_default_model_adapter_manager.py index 78b79ee4..048db1fc 100644 --- a/tests/unit/test_default_model_adapter_manager.py +++ b/tests/unit/test_default_model_adapter_manager.py @@ -4,7 +4,7 @@ from dare_framework.agent import BaseAgent from dare_framework.config.types import Config, LLMConfig -from dare_framework.model import OpenAIModelAdapter, OpenRouterModelAdapter +from dare_framework.model import AnthropicModelAdapter, OpenAIModelAdapter, OpenRouterModelAdapter from dare_framework.model.default_model_adapter_manager import DefaultModelAdapterManager @@ -24,6 +24,15 @@ def test_default_manager_returns_openrouter_adapter() -> None: assert adapter.model_name == "openrouter/test" +def test_default_manager_returns_anthropic_adapter() -> None: + manager = DefaultModelAdapterManager() + config = Config(llm=LLMConfig(adapter="anthropic", api_key="test-key", model="claude-sonnet-4-5")) + adapter = manager.load_model_adapter(config=config) + assert isinstance(adapter, AnthropicModelAdapter) + assert adapter.name == "anthropic" + assert adapter.model_name == "claude-sonnet-4-5" + + def test_default_manager_unsupported_adapter_raises() -> None: manager = DefaultModelAdapterManager() config = Config(llm=LLMConfig(adapter="unknown")) From 06330ef384b8964cdb0d833a74150d14f939423e Mon Sep 17 00:00:00 2001 From: mindfn Date: Thu, 5 Mar 2026 16:27:46 +0800 Subject: [PATCH 08/12] Revert "feat(model): add standalone Anthropic adapter with direct model pass-through" This reverts commit ed550e861b9319ad47bdb7a0646c96b2a7ac57c5. --- client/README.md | 28 +- client/commands/info.py | 6 +- client/examples/config.anthropic.example.json | 6 - client/main.py | 2 +- dare_framework/model/__init__.py | 3 +- dare_framework/model/adapters/__init__.py | 3 +- .../model/adapters/anthropic_adapter.py | 391 ------------------ .../model/adapters/openrouter_adapter.py | 4 +- .../model/default_model_adapter_manager.py | 16 +- docs/design/DARE_Formal_Design.md | 2 +- docs/design/modules/model/README.md | 9 +- docs/features/README.md | 1 - docs/features/add-anthropic-model-adapter.md | 86 ---- .../.openspec.yaml | 2 - .../add-anthropic-model-adapter/design.md | 55 --- .../add-anthropic-model-adapter/proposal.md | 37 -- .../specs/anthropic-model-adapter/spec.md | 35 -- .../add-anthropic-model-adapter/tasks.md | 17 - pyproject.toml | 1 - requirements.txt | 1 - tests/unit/test_anthropic_model_adapter.py | 158 ------- tests/unit/test_client_cli.py | 19 - .../test_default_model_adapter_manager.py | 11 +- 23 files changed, 13 insertions(+), 880 deletions(-) delete mode 100644 client/examples/config.anthropic.example.json delete mode 100644 dare_framework/model/adapters/anthropic_adapter.py delete mode 100644 docs/features/add-anthropic-model-adapter.md delete mode 100644 openspec/changes/add-anthropic-model-adapter/.openspec.yaml delete mode 100644 openspec/changes/add-anthropic-model-adapter/design.md delete mode 100644 openspec/changes/add-anthropic-model-adapter/proposal.md delete mode 100644 openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md delete mode 100644 openspec/changes/add-anthropic-model-adapter/tasks.md delete mode 100644 tests/unit/test_anthropic_model_adapter.py diff --git a/client/README.md b/client/README.md index 0a8ca02e..1a98b0c8 100644 --- a/client/README.md +++ b/client/README.md @@ -123,10 +123,10 @@ export OPENROUTER_API_KEY=sk-or-... ### `llm` 字段说明 -- `adapter`:模型适配器,当前支持 `openai`、`openrouter`、`anthropic`。不写时默认是 `openai`。 +- `adapter`:模型适配器,当前支持 `openai` 和 `openrouter`。不写时默认是 `openai`。 - `model`:模型名,例如 `gpt-4o-mini`、`gpt-4.1`、`qwen/qwen3-coder:free`。 - `api_key`:模型服务密钥。也可以通过环境变量提供。 -- `endpoint`:自定义 provider base URL。对 `openrouter`/`anthropic` 来说分别映射到各自 SDK 的 `base_url`。 +- `endpoint`:自定义 OpenAI-compatible base URL。对 `openrouter` 来说会作为 `base_url` 使用。 - `proxy`:代理配置,支持 `http`、`https`、`no_proxy`、`use_system_proxy`、`disabled`。 - 其他未显式声明的字段会进入 `llm.extra`,并透传给 adapter;例如可以直接写 `temperature`、`max_tokens`。 @@ -191,29 +191,6 @@ export OPENROUTER_MODEL=qwen/qwen3-coder:free export OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 ``` -Anthropic: - -```json -{ - "llm": { - "adapter": "anthropic", - "model": "claude-sonnet-4-5" - } -} -``` - -配合环境变量: - -```bash -export ANTHROPIC_API_KEY=sk-ant-... -# 建议显式写完整模型名;CLI 会直接透传到 Anthropic SDK -export ANTHROPIC_MODEL=claude-sonnet-4-5 -``` - -仓库示例文件: - -- `client/examples/config.anthropic.example.json` - OpenAI-compatible / 自建模型网关: ```json @@ -317,7 +294,6 @@ OpenAI-compatible / 自建模型网关: - `openai` adapter:需要 `langchain-openai` - `openrouter` adapter:需要 `openai` -- `anthropic` adapter:需要 `anthropic` 否则 `doctor` 会提示 adapter probe 或依赖缺失,runtime 也无法正常启动。 diff --git a/client/commands/info.py b/client/commands/info.py index 9f8de17c..e1f5e478 100644 --- a/client/commands/info.py +++ b/client/commands/info.py @@ -52,7 +52,6 @@ def build_doctor_report( api_key_sources = { "openrouter": bool(config.llm.api_key or os.getenv("OPENROUTER_API_KEY")), "openai": bool(config.llm.api_key or os.getenv("OPENAI_API_KEY")), - "anthropic": bool(config.llm.api_key or os.getenv("ANTHROPIC_API_KEY")), } workspace = Path(config.workspace_dir).expanduser().resolve() user_dir = Path(config.user_dir).expanduser().resolve() @@ -73,7 +72,6 @@ def build_doctor_report( "httpx_installed": importlib.util.find_spec("httpx") is not None, "openai_sdk_installed": importlib.util.find_spec("openai") is not None, "langchain_openai_installed": importlib.util.find_spec("langchain_openai") is not None, - "anthropic_sdk_installed": importlib.util.find_spec("anthropic") is not None, } diagnostics: dict[str, Any] = { @@ -97,7 +95,7 @@ def build_doctor_report( warnings: list[str] = [] if not diagnostics["workspace_exists"]: warnings.append("workspace_dir does not exist") - if adapter not in {"openai", "openrouter", "anthropic"}: + if adapter not in {"openai", "openrouter"}: warnings.append(f"unsupported adapter configured: {adapter}") if not diagnostics["llm"]["api_key_present"]: warnings.append(f"missing API key for adapter={adapter}") @@ -113,8 +111,6 @@ def build_doctor_report( warnings.append("openai SDK is required for openrouter adapter") if adapter == "openai" and not deps["langchain_openai_installed"]: warnings.append("langchain-openai is required for openai adapter") - if adapter == "anthropic" and not deps["anthropic_sdk_installed"]: - warnings.append("anthropic SDK is required for anthropic adapter") diagnostics["warnings"] = warnings diagnostics["ok"] = len(warnings) == 0 return diagnostics diff --git a/client/examples/config.anthropic.example.json b/client/examples/config.anthropic.example.json deleted file mode 100644 index 62ca1f8a..00000000 --- a/client/examples/config.anthropic.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "llm": { - "adapter": "anthropic", - "model": "claude-sonnet-4-5" - } -} diff --git a/client/main.py b/client/main.py index fa50de49..5a1d9a38 100644 --- a/client/main.py +++ b/client/main.py @@ -1300,7 +1300,7 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="DARE external CLI") parser.add_argument("--workspace", default=str(Path.cwd()), help="workspace root path") parser.add_argument("--user-dir", default=str(Path.home()), help="user directory path") - parser.add_argument("--adapter", default=None, help="llm adapter override (openai/openrouter/anthropic)") + parser.add_argument("--adapter", default=None, help="llm adapter override (openai/openrouter)") parser.add_argument("--model", default=None, help="llm model override") parser.add_argument("--api-key", default=None, help="llm api key override") parser.add_argument("--endpoint", default=None, help="llm endpoint override") diff --git a/dare_framework/model/__init__.py b/dare_framework/model/__init__.py index 45e2c1da..3afc7a8c 100644 --- a/dare_framework/model/__init__.py +++ b/dare_framework/model/__init__.py @@ -9,7 +9,7 @@ from dare_framework.model.builtin_prompt_loader import BuiltInPromptLoader from dare_framework.model.filesystem_prompt_loader import FileSystemPromptLoader from dare_framework.model.layered_prompt_store import LayeredPromptStore -from dare_framework.model.adapters import AnthropicModelAdapter, OpenAIModelAdapter, OpenRouterModelAdapter +from dare_framework.model.adapters import OpenAIModelAdapter, OpenRouterModelAdapter __all__ = [ "IModelAdapter", @@ -24,7 +24,6 @@ "BuiltInPromptLoader", "FileSystemPromptLoader", "LayeredPromptStore", - "AnthropicModelAdapter", "OpenAIModelAdapter", "OpenRouterModelAdapter", ] diff --git a/dare_framework/model/adapters/__init__.py b/dare_framework/model/adapters/__init__.py index e0662803..b7bfd029 100644 --- a/dare_framework/model/adapters/__init__.py +++ b/dare_framework/model/adapters/__init__.py @@ -1,7 +1,6 @@ """Model adapters.""" -from dare_framework.model.adapters.anthropic_adapter import AnthropicModelAdapter from dare_framework.model.adapters.openai_adapter import OpenAIModelAdapter from dare_framework.model.adapters.openrouter_adapter import OpenRouterModelAdapter -__all__ = ["AnthropicModelAdapter", "OpenAIModelAdapter", "OpenRouterModelAdapter"] +__all__ = ["OpenAIModelAdapter", "OpenRouterModelAdapter"] diff --git a/dare_framework/model/adapters/anthropic_adapter.py b/dare_framework/model/adapters/anthropic_adapter.py deleted file mode 100644 index 30384608..00000000 --- a/dare_framework/model/adapters/anthropic_adapter.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Anthropic model adapter using the official anthropic SDK.""" - -from __future__ import annotations - -import json -import os -from typing import Any - -from dare_framework.model.kernel import IModelAdapter -from dare_framework.model.types import GenerateOptions, ModelInput, ModelResponse - - -class AnthropicModelAdapter(IModelAdapter): - """Model adapter for Anthropic Messages API.""" - - def __init__( - self, - *, - name: str | None = None, - api_key: str | None = None, - model: str | None = None, - base_url: str | None = None, - http_client_options: dict[str, Any] | None = None, - extra: dict[str, Any] | None = None, - ) -> None: - self._name = name or "anthropic" - self._api_key = api_key or os.getenv("ANTHROPIC_API_KEY") - self._model = _resolve_model_name(model=model, env_model=os.getenv("ANTHROPIC_MODEL")) - self._base_url = base_url or os.getenv("ANTHROPIC_BASE_URL") - self._http_client_options = dict(http_client_options or {}) - self._extra = dict(extra or {}) - self._client: Any = None - - if not self._api_key: - raise ValueError("Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable.") - - @property - def name(self) -> str: - return self._name - - @property - def model(self) -> str: - return self._model - - @property - def model_name(self) -> str: - return self._model - - async def generate( - self, - model_input: ModelInput, - *, - options: GenerateOptions | None = None, - ) -> ModelResponse: - client = self._ensure_client() - system_prompt, messages = _serialize_system_and_messages(model_input.messages) - - params: dict[str, Any] = { - "model": self._model, - "messages": messages, - "max_tokens": _resolve_max_tokens(options=options, extra=self._extra), - } - if system_prompt: - params["system"] = system_prompt - if model_input.tools: - params["tools"] = [ - { - "name": tool.name, - "description": tool.description, - "input_schema": tool.input_schema, - } - for tool in model_input.tools - ] - - if self._extra: - params.update(self._extra) - if options is not None: - if options.temperature is not None: - params["temperature"] = options.temperature - if options.max_tokens is not None: - params["max_tokens"] = options.max_tokens - if options.top_p is not None: - params["top_p"] = options.top_p - if options.stop is not None: - params["stop_sequences"] = options.stop - - response = await client.messages.create(**params) - content_blocks = list(getattr(response, "content", []) or []) - - return ModelResponse( - content=_extract_response_text(content_blocks), - tool_calls=_extract_tool_calls(content_blocks), - usage=_extract_usage(getattr(response, "usage", None)), - thinking_content=_extract_thinking_content(content_blocks), - metadata={ - "model": self._model, - "stop_reason": getattr(response, "stop_reason", None), - }, - ) - - def _ensure_client(self) -> Any: - if self._client is None: - self._client = self._build_client() - return self._client - - def _build_client(self) -> Any: - try: - from anthropic import AsyncAnthropic - except ImportError as exc: - raise ImportError( - "anthropic SDK is required for AnthropicModelAdapter. Install with: pip install anthropic" - ) from exc - - client_kwargs: dict[str, Any] = {"api_key": self._api_key} - if self._base_url: - client_kwargs["base_url"] = self._base_url - - http_client = _build_async_http_client(self._http_client_options) - if http_client is not None: - client_kwargs["http_client"] = http_client - - return AsyncAnthropic(**client_kwargs) - - -def _resolve_model_name(*, model: str | None, env_model: str | None) -> str: - selected = model or env_model - if not selected or not selected.strip(): - raise ValueError( - "Anthropic model is required. Set Config.llm.model or ANTHROPIC_MODEL environment variable." - ) - return selected.strip() - - -def _resolve_max_tokens(*, options: GenerateOptions | None, extra: dict[str, Any]) -> int: - if options is not None and options.max_tokens is not None: - return int(options.max_tokens) - extra_value = extra.get("max_tokens") - if extra_value is not None: - return int(extra_value) - env_value = os.getenv("ANTHROPIC_MAX_TOKENS") - if env_value is not None: - try: - return int(env_value) - except ValueError: - pass - return 2048 - - -def _serialize_system_and_messages(messages: list[Any]) -> tuple[str | None, list[dict[str, Any]]]: - system_parts: list[str] = [] - payload: list[dict[str, Any]] = [] - - for msg in messages: - role = str(getattr(msg, "role", "user")) - content = str(getattr(msg, "content", "")) - if role == "system": - text = content.strip() - if text: - system_parts.append(text) - continue - - if role == "assistant": - message_content: list[dict[str, Any]] = [] - if content: - message_content.append({"type": "text", "text": content}) - tool_calls = _normalize_tool_calls(getattr(msg, "metadata", {}).get("tool_calls", [])) - message_content.extend(tool_calls) - if not message_content: - message_content.append({"type": "text", "text": ""}) - payload.append({"role": "assistant", "content": message_content}) - continue - - if role == "tool": - tool_call_id = getattr(msg, "name", None) or "tool_call" - payload.append( - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool_call_id, - "content": content, - } - ], - } - ) - continue - - payload.append({"role": "user", "content": content}) - - system_prompt = "\n\n".join(system_parts) if system_parts else None - return system_prompt, payload - - -def _normalize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]: - if not isinstance(tool_calls, list): - return [] - - normalized: list[dict[str, Any]] = [] - for index, call in enumerate(tool_calls): - if not isinstance(call, dict): - continue - call_id = call.get("id") or call.get("tool_call_id") or f"toolu_{index + 1}" - name = call.get("name") - if not isinstance(name, str) or not name.strip(): - continue - arguments = call.get("arguments", call.get("args", {})) - if isinstance(arguments, str): - try: - arguments = json.loads(arguments) - except json.JSONDecodeError: - arguments = {"raw": arguments} - if not isinstance(arguments, dict): - arguments = {} - normalized.append( - { - "type": "tool_use", - "id": str(call_id), - "name": name, - "input": arguments, - } - ) - return normalized - - -def _extract_response_text(content_blocks: list[Any]) -> str: - parts: list[str] = [] - for block in content_blocks: - if _block_type(block) != "text": - continue - text = _coerce_text(_block_value(block, "text")) - if text: - parts.append(text) - return "\n".join(parts) - - -def _extract_thinking_content(content_blocks: list[Any]) -> str | None: - parts: list[str] = [] - for block in content_blocks: - if _block_type(block) not in {"thinking", "reasoning"}: - continue - text = ( - _coerce_text(_block_value(block, "thinking")) - or _coerce_text(_block_value(block, "reasoning")) - or _coerce_text(_block_value(block, "text")) - or _coerce_text(_block_value(block, "content")) - ) - if text: - parts.append(text) - if not parts: - return None - return "\n".join(parts) - - -def _extract_tool_calls(content_blocks: list[Any]) -> list[dict[str, Any]]: - calls: list[dict[str, Any]] = [] - for block in content_blocks: - if _block_type(block) != "tool_use": - continue - call_id = _block_value(block, "id") - name = _block_value(block, "name") - if not isinstance(name, str) or not name.strip(): - continue - arguments = _block_value(block, "input") - if not isinstance(arguments, dict): - arguments = {} - calls.append( - { - "id": str(call_id) if call_id is not None else None, - "name": name, - "arguments": arguments, - } - ) - return calls - - -def _extract_usage(usage: Any) -> dict[str, Any] | None: - if usage is None: - return None - - prompt_tokens = _to_int(_get_nested_value(usage, "input_tokens")) - completion_tokens = _to_int(_get_nested_value(usage, "output_tokens")) - total_tokens = ( - prompt_tokens + completion_tokens - if prompt_tokens is not None and completion_tokens is not None - else _to_int(_get_nested_value(usage, "total_tokens")) - ) - - payload: dict[str, Any] = {} - if prompt_tokens is not None: - payload["prompt_tokens"] = prompt_tokens - if completion_tokens is not None: - payload["completion_tokens"] = completion_tokens - if total_tokens is not None: - payload["total_tokens"] = total_tokens - - reasoning_tokens = _extract_reasoning_tokens(usage) - if reasoning_tokens is not None: - payload["reasoning_tokens"] = reasoning_tokens - - if not payload: - return None - return payload - - -def _extract_reasoning_tokens(usage: Any) -> int | None: - candidates = [ - _get_nested_value(usage, "reasoning_tokens"), - _get_nested_value(_get_nested_value(usage, "output_tokens_details"), "reasoning_tokens"), - _get_nested_value(_get_nested_value(usage, "output_tokens_details"), "thinking_tokens"), - _get_nested_value(_get_nested_value(usage, "completion_tokens_details"), "reasoning_tokens"), - ] - for candidate in candidates: - value = _to_int(candidate) - if value is not None: - return value - return None - - -def _block_type(block: Any) -> str | None: - if isinstance(block, dict): - value = block.get("type") - else: - value = getattr(block, "type", None) - return str(value) if value is not None else None - - -def _block_value(block: Any, key: str) -> Any: - if isinstance(block, dict): - return block.get(key) - return getattr(block, key, None) - - -def _get_nested_value(value: Any, key: str) -> Any: - if isinstance(value, dict): - return value.get(key) - return getattr(value, key, None) - - -def _to_int(value: Any) -> int | None: - try: - if value is None: - return None - return int(value) - except (TypeError, ValueError): - return None - - -def _coerce_text(value: Any) -> str | None: - if isinstance(value, str): - text = value.strip() - return text or None - if isinstance(value, dict): - for key in ("text", "content", "thinking", "reasoning"): - text = _coerce_text(value.get(key)) - if text: - return text - return None - if isinstance(value, list): - parts: list[str] = [] - for item in value: - text = _coerce_text(item) - if text: - parts.append(text) - if parts: - return "\n".join(parts) - return None - - -def _build_async_http_client(options: dict[str, Any]) -> Any | None: - if not options: - return None - try: - import httpx - except Exception: - return None - try: - return httpx.AsyncClient(**options) - except Exception: - return None - - -__all__ = [ - "AnthropicModelAdapter", - "_extract_response_text", - "_extract_thinking_content", - "_extract_tool_calls", - "_extract_usage", - "_resolve_model_name", - "_serialize_system_and_messages", -] diff --git a/dare_framework/model/adapters/openrouter_adapter.py b/dare_framework/model/adapters/openrouter_adapter.py index 1177efb2..80ceb20e 100644 --- a/dare_framework/model/adapters/openrouter_adapter.py +++ b/dare_framework/model/adapters/openrouter_adapter.py @@ -4,10 +4,12 @@ import json import os -from typing import Any +from typing import Any, Literal +from dare_framework.infra.component import ComponentType from dare_framework.model.kernel import IModelAdapter from dare_framework.model.types import GenerateOptions, ModelInput, ModelResponse +from dare_framework.tool.types import CapabilityDescriptor class OpenRouterModelAdapter(IModelAdapter): diff --git a/dare_framework/model/default_model_adapter_manager.py b/dare_framework/model/default_model_adapter_manager.py index 71f2b09b..247417ee 100644 --- a/dare_framework/model/default_model_adapter_manager.py +++ b/dare_framework/model/default_model_adapter_manager.py @@ -7,7 +7,6 @@ from dare_framework.config.types import Config, LLMConfig from dare_framework.model.interfaces import IModelAdapterManager from dare_framework.model.kernel import IModelAdapter -from dare_framework.model.adapters.anthropic_adapter import AnthropicModelAdapter from dare_framework.model.adapters.openai_adapter import OpenAIModelAdapter from dare_framework.model.adapters.openrouter_adapter import OpenRouterModelAdapter @@ -28,10 +27,8 @@ def load_model_adapter(self, *, config: Config | None = None) -> IModelAdapter | return _build_openai_adapter(llm) if adapter_name == "openrouter": return _build_openrouter_adapter(llm) - if adapter_name == "anthropic": - return _build_anthropic_adapter(llm) raise ValueError( - f"Unsupported model adapter '{adapter_name}'. Supported adapters: openai, openrouter, anthropic." + f"Unsupported model adapter '{adapter_name}'. Supported adapters: openai, openrouter." ) @@ -64,17 +61,6 @@ def _build_openrouter_adapter(llm: LLMConfig) -> OpenRouterModelAdapter: ) -def _build_anthropic_adapter(llm: LLMConfig) -> AnthropicModelAdapter: - return AnthropicModelAdapter( - name="anthropic", - api_key=llm.api_key, - model=llm.model, - base_url=llm.endpoint, - http_client_options=_http_client_options_from_proxy(llm), - extra=dict(llm.extra), - ) - - def _http_client_options_from_proxy(llm: LLMConfig) -> dict[str, Any]: proxy = llm.proxy options: dict[str, Any] = {} diff --git a/docs/design/DARE_Formal_Design.md b/docs/design/DARE_Formal_Design.md index 69be4b96..1e40ca6f 100644 --- a/docs/design/DARE_Formal_Design.md +++ b/docs/design/DARE_Formal_Design.md @@ -383,7 +383,7 @@ flowchart LR - **职责**:统一模型调用入口 + Prompt 管理。 - **关键类型**:`ModelInput` / `ModelResponse` / `Prompt`。 - **核心接口**:`IModelAdapter` / `IModelAdapterManager` / `IPromptStore`。 -- **默认实现**:OpenAI/OpenRouter/Anthropic 适配器 + LayeredPromptStore。 +- **默认实现**:OpenAI/OpenRouter 适配器 + LayeredPromptStore。 - **扩展点**:多模型路由、流式输出、Prompt Loader。 - **现状限制**:无流式输出与多阶段 prompt pack。 diff --git a/docs/design/modules/model/README.md b/docs/design/modules/model/README.md index 79889b55..babf0707 100644 --- a/docs/design/modules/model/README.md +++ b/docs/design/modules/model/README.md @@ -1,6 +1,6 @@ # Module: model -> Status: detailed design aligned to `dare_framework/model` (2026-03-05). +> Status: detailed design aligned to `dare_framework/model` (2026-02-25). ## 1. 定位与职责 @@ -58,12 +58,6 @@ flowchart TD - **Config**:`Config.llm` 决定 adapter 类型与连接参数。 - **Observability**:从 `usage` 提取 token 指标。 -## 6.1 默认 Adapter 能力矩阵 - -- `openai`: 基于 `langchain-openai`,适配 OpenAI-compatible Chat 接口。 -- `openrouter`: 基于 `openai` SDK,适配 OpenRouter OpenAI-compatible 接口。 -- `anthropic`: 基于 `anthropic` 官方 SDK,适配 Anthropic Messages API(模型名透传 + tool blocks)。 - ## 7. 约束与限制 - 当前流式输出和增量 tool-call 仍是待补齐项。 @@ -97,4 +91,3 @@ flowchart TD - `tests/unit/test_default_model_adapter_manager.py`(模型适配器管理与选择) - `tests/unit/test_openrouter_adapter.py`(adapter 消息序列化与 tool-call 兼容) -- `tests/unit/test_anthropic_model_adapter.py`(Anthropic adapter 请求/响应规范化) diff --git a/docs/features/README.md b/docs/features/README.md index b8cd1c3a..cb33119a 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -37,7 +37,6 @@ ## Active Entries -- `docs/features/add-anthropic-model-adapter.md` - `docs/features/agentscope-d2-d4-thinking-transport.md` - `docs/features/agentscope-d5-safe-compression.md` - `docs/features/agentscope-d7-plan-state-tools.md` diff --git a/docs/features/add-anthropic-model-adapter.md b/docs/features/add-anthropic-model-adapter.md deleted file mode 100644 index c1c7db63..00000000 --- a/docs/features/add-anthropic-model-adapter.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -change_ids: ["add-anthropic-model-adapter"] -doc_kind: feature -topics: ["model", "anthropic", "adapter", "cli-doctor"] -created: 2026-03-05 -updated: 2026-03-05 -status: draft -mode: openspec ---- - -# Feature: add-anthropic-model-adapter - -## Scope -新增独立 `AnthropicModelAdapter`,对接 Anthropic 官方 Messages API,并在 runtime/CLI 侧完成 `anthropic` adapter 的加载与诊断接入,不改造既有 OpenAI/OpenRouter adapter 内部实现。 - -## OpenSpec Artifacts -- Proposal: `openspec/changes/add-anthropic-model-adapter/proposal.md` -- Design: `openspec/changes/add-anthropic-model-adapter/design.md` -- Specs: - - `openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md` -- Tasks: `openspec/changes/add-anthropic-model-adapter/tasks.md` - -## Progress -- 已完成:Anthropic adapter 代码、manager 接入、CLI doctor 接入、依赖与文档更新。 -- 已完成:定向单测回归通过。 -- 待完成:评审链接与归档动作。 - -## Evidence - -### External References -- Anthropic Messages API: `https://docs.anthropic.com/en/api/messages` -- Anthropic tool use examples: `https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview` -- OpenRouter Anthropic Opus model page: `https://openrouter.ai/anthropic/claude-opus-4.1` -- OpenRouter Anthropic Sonnet model page: `https://openrouter.ai/anthropic/claude-sonnet-4.5` - -### Commands -- `openspec new change "add-anthropic-model-adapter"` -- `openspec status --change "add-anthropic-model-adapter" --json` -- `.venv/bin/pytest -q tests/unit/test_anthropic_model_adapter.py tests/unit/test_default_model_adapter_manager.py tests/unit/test_client_cli.py` - -### Results -- OpenSpec change 创建成功(schema: `spec-driven`)。 -- Anthropic adapter 目标测试集通过:`58 passed, 1 warning`。 - -### Contract Delta -- schema: `changed`(新增 `anthropic` adapter 选择与请求/响应规范化行为,模型名为配置直传)。 -- error semantics: `changed`(`anthropic` SDK 缺失时新增明确错误提示)。 -- retry: `none`(未新增重试语义)。 - -### Golden Cases -- `tests/unit/test_anthropic_model_adapter.py` -- `tests/unit/test_default_model_adapter_manager.py` -- `tests/unit/test_client_cli.py` - -### Regression Summary -- 定向回归通过,覆盖新增 adapter 的序列化、模型名直传、manager 选择与 CLI doctor 诊断分支。 - -### Observability and Failure Localization -- 入口:`DefaultModelAdapterManager.load_model_adapter(config)`。 -- tool_call/tool_result 序列化:`AnthropicModelAdapter._serialize_system_and_messages`。 -- 响应解析:`_extract_response_text` / `_extract_tool_calls` / `_extract_usage`。 -- 失败定位:`_build_client`(依赖缺失)、`build_doctor_report`(依赖与 API key 诊断)。 - -### Structured Review Report -- module boundary: 仅新增 Anthropic adapter 模块并修改最小接入面(manager/export/doctor)。 -- state: 无新增全局可变状态,client 延续 lazy client 初始化。 -- concurrency: 复用 async client 调用模型 API,不引入并发共享写路径。 -- side-effect: 新增外部依赖 `anthropic`,其余 side-effect 不变。 -- coverage: 新增/修改行为均有对应单测。 - -### Behavior Verification -- Happy path: - - `adapter=anthropic` 时可加载 Anthropic adapter 并完成消息/工具块序列化与响应归一化。 - - 显式模型名(例如 `claude-sonnet-4-5`)会原样透传到 Anthropic SDK。 -- Error branch: - - 缺少 `ANTHROPIC_API_KEY` 或 `api_key` 时抛出显式错误。 - - 缺少 `Config.llm.model` 且 `ANTHROPIC_MODEL` 未设置时抛出模型必填错误。 - - doctor 在 `adapter=anthropic` 且未安装 `anthropic` SDK 时输出依赖告警。 - -### Risks and Rollback -- 风险:运行配置缺少模型名会导致 adapter 初始化失败。 -- 回滚:删除 `anthropic` 分支与新增 adapter 文件即可恢复至原行为,不影响 OpenAI/OpenRouter 路径。 - -### Review and Merge Gate Links -- Review request: 待创建 PR 后补充。 -- Merge gate: 待 CI 与 reviewer 通过后补充。 diff --git a/openspec/changes/add-anthropic-model-adapter/.openspec.yaml b/openspec/changes/add-anthropic-model-adapter/.openspec.yaml deleted file mode 100644 index 8f0b8699..00000000 --- a/openspec/changes/add-anthropic-model-adapter/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-03-05 diff --git a/openspec/changes/add-anthropic-model-adapter/design.md b/openspec/changes/add-anthropic-model-adapter/design.md deleted file mode 100644 index 68dc0550..00000000 --- a/openspec/changes/add-anthropic-model-adapter/design.md +++ /dev/null @@ -1,55 +0,0 @@ -## Context - -现有 model domain 的默认适配器仅覆盖 OpenAI / OpenRouter。用户需要“完整新增”Anthropic adapter,而不是在已有 adapter 上做兼容分支。该改动涉及模型调用层、默认 adapter 管理器、CLI 诊断链路和文档契约更新,属于跨模块变更。 - -## Goals / Non-Goals - -**Goals:** -- 提供独立 `AnthropicModelAdapter`,并保持与现有 `IModelAdapter` 契约一致。 -- 模型名仅做直传:由 `Config.llm.model` 或 `ANTHROPIC_MODEL` 提供,不维护硬编码别名映射。 -- 支持 tool calling 对话回放链路:assistant `tool_use` 与 tool `tool_result`。 -- 在 CLI doctor 中加入 `anthropic` adapter 的配置与依赖可观测性。 - -**Non-Goals:** -- 不改造 OpenAI / OpenRouter adapter 的内部逻辑。 -- 不实现 streaming 接口。 -- 不新增多 provider 自动路由策略。 - -## Decisions - -1. **独立实现 Anthropic adapter** -- Decision: 新建 `dare_framework/model/adapters/anthropic_adapter.py`,不复用 `openrouter_adapter.py`。 -- Rationale: 用户要求“完整新增”;Anthropic Messages API 的 message/tool 结构也与 OpenAI-compatible 接口不同。 -- Alternative considered: 在 OpenRouter adapter 内新增 Anthropic 分支,违背隔离要求且会放大耦合。 - -2. **模型名来源固定为配置/环境变量** -- Decision: 适配器不做模型别名转换,仅接受 `Config.llm.model` 或 `ANTHROPIC_MODEL` 的直接值。 -- Rationale: 避免“新模型发布 -> 需要改框架代码”耦合,模型升级由配置侧完成。 -- Alternative considered: 内置别名映射;该方案会引入版本漂移与维护负担。 - -3. **序列化策略对齐 Anthropic Messages API** -- Decision: `system` 消息单独抽取到 `system` 字段;assistant 历史 tool call 序列化为 `tool_use` block;tool 回包序列化为 user `tool_result` block。 -- Rationale: 与 Anthropic 官方 tool-use 协议一致,保证多轮工具调用可追踪。 - -4. **CLI doctor 扩展 anthropic 诊断** -- Decision: 新增 `ANTHROPIC_API_KEY` 来源检查和 `anthropic` SDK 依赖检查。 -- Rationale: 让运行前诊断与新增 adapter 保持一致,避免误判“unsupported adapter”。 - -## Risks / Trade-offs - -- [Risk] Anthropic SDK 未安装导致运行期失败。 - Mitigation: doctor 增加依赖告警;adapter 在 `_build_client` 处抛出明确 ImportError。 -- [Risk] Anthropic API block 字段未来变化导致解析漂移。 - Mitigation: 解析函数统一封装 `_extract_*`,并以单元测试锚定。 -- [Risk] 用户未配置模型名会导致启动失败。 - Mitigation: 在 adapter 初始化阶段给出明确错误信息;README 与示例文件提供最小配置模板。 - -## Migration Plan - -1. 添加 Anthropic adapter 与单测(先红后绿)。 -2. 接入 manager/export/doctor 并补齐相关测试。 -3. 更新设计文档与 client 使用文档。 -4. 运行定向测试验证新增能力。 - -Rollback: -- 若出现兼容问题,可仅回退 `default_model_adapter_manager.py` 中 `anthropic` 分支和新增 adapter 文件,不影响既有 `openai/openrouter` 路径。 diff --git a/openspec/changes/add-anthropic-model-adapter/proposal.md b/openspec/changes/add-anthropic-model-adapter/proposal.md deleted file mode 100644 index eaf10429..00000000 --- a/openspec/changes/add-anthropic-model-adapter/proposal.md +++ /dev/null @@ -1,37 +0,0 @@ -## Why - -当前框架仅内置 `openai` / `openrouter` 两类模型适配器,无法直接对接 Anthropic 官方 Messages API。用户明确需要独立 Anthropic adapter,并要求模型名由用户配置直传,避免因新模型发布而改代码。 - -## What Changes - -- 新增独立 `AnthropicModelAdapter`,基于 Anthropic 官方 Python SDK 的 `messages.create` 接口实现。 -- 在 adapter 内统一处理消息序列化(含 tool history / tool result)、响应反序列化(text/tool_use/thinking/usage)。 -- 在 `DefaultModelAdapterManager` 增加 `anthropic` 分支,支持通过 `Config.llm.adapter="anthropic"` 加载。 -- 在 CLI doctor 诊断中增加 `anthropic` adapter 的 API Key 与依赖检查。 -- 更新 model/client 设计与使用文档,补充 Anthropic 配置与官方参考链接。 - -## Capabilities - -### New Capabilities -- `anthropic-model-adapter`: Anthropic 官方 API 的模型适配能力(模型名直传,tool calling,usage/thinking 归一化)。 - -### Modified Capabilities -- None. - -## Impact - -- Affected code: - - `dare_framework/model/adapters/anthropic_adapter.py` - - `dare_framework/model/adapters/__init__.py` - - `dare_framework/model/__init__.py` - - `dare_framework/model/default_model_adapter_manager.py` - - `client/commands/info.py` - - `client/main.py` - - `pyproject.toml` - - `requirements.txt` -- Affected tests: - - `tests/unit/test_anthropic_model_adapter.py` - - `tests/unit/test_default_model_adapter_manager.py` - - `tests/unit/test_client_cli.py` -- New dependency: - - `anthropic` (official SDK) diff --git a/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md b/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md deleted file mode 100644 index 93e347c7..00000000 --- a/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md +++ /dev/null @@ -1,35 +0,0 @@ -## ADDED Requirements - -### Requirement: Anthropic adapter uses direct model-name pass-through -The model domain SHALL provide an Anthropic adapter that can be selected via `Config.llm.adapter="anthropic"` and MUST accept model names as direct pass-through values from `Config.llm.model` or `ANTHROPIC_MODEL`. - -#### Scenario: Adapter uses explicit model from config -- **WHEN** config provides model `claude-sonnet-4-5` -- **THEN** the adapter request uses `claude-sonnet-4-5` as-is - -#### Scenario: Adapter requires model source when unset -- **WHEN** both `Config.llm.model` and `ANTHROPIC_MODEL` are missing -- **THEN** adapter initialization fails with an explicit model-required error - -### Requirement: Anthropic adapter serializes tool history using Messages API blocks -The Anthropic adapter SHALL serialize assistant tool-call history and tool results according to Anthropic Messages API content blocks. - -#### Scenario: Assistant history contains tool calls -- **WHEN** assistant history metadata includes tool calls -- **THEN** the adapter emits `tool_use` blocks with stable ids, names, and JSON object inputs - -#### Scenario: Tool result is appended to history -- **WHEN** a tool result message is present in history -- **THEN** the adapter emits a user `tool_result` block that references the originating `tool_use_id` - -### Requirement: Anthropic adapter normalizes response content and usage -The Anthropic adapter SHALL normalize response text, tool calls, thinking content, and usage fields into `ModelResponse`. - -#### Scenario: Response includes text and tool_use blocks -- **WHEN** Anthropic response content contains `text` and `tool_use` blocks -- **THEN** `ModelResponse.content` contains the text content -- **AND** `ModelResponse.tool_calls` contains normalized tool calls - -#### Scenario: Response includes usage counters -- **WHEN** Anthropic response usage provides input/output token counters -- **THEN** `ModelResponse.usage` includes `prompt_tokens`, `completion_tokens`, and `total_tokens` diff --git a/openspec/changes/add-anthropic-model-adapter/tasks.md b/openspec/changes/add-anthropic-model-adapter/tasks.md deleted file mode 100644 index d0480c13..00000000 --- a/openspec/changes/add-anthropic-model-adapter/tasks.md +++ /dev/null @@ -1,17 +0,0 @@ -## 1. Anthropic adapter implementation - -- [x] 1.1 Add `AnthropicModelAdapter` with Anthropic Messages API request/response normalization. -- [x] 1.2 Enforce model-name pass-through from config/env without hard-coded alias mapping. -- [x] 1.3 Add adapter-focused unit tests for serialization, parsing, and generate-path payload building. - -## 2. Runtime and CLI integration - -- [x] 2.1 Wire `anthropic` into `DefaultModelAdapterManager` and model facade exports. -- [x] 2.2 Extend CLI doctor diagnostics for `anthropic` adapter key/dependency checks. -- [x] 2.3 Update unit tests covering manager selection and doctor behavior. - -## 3. Documentation and dependency sync - -- [x] 3.1 Update model/client documentation to include Anthropic adapter usage and constraints. -- [x] 3.2 Add governance feature/evidence document for this change. -- [x] 3.3 Update dependency manifests to include Anthropic SDK. diff --git a/pyproject.toml b/pyproject.toml index e2f361ae..6c9fc37f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ version = "0.1.0" description = "DARE Framework and external CLI runtime" requires-python = ">=3.12" dependencies = [ - "anthropic", "langchain-openai", "langchain-core", "httpx>=0.27.0", diff --git a/requirements.txt b/requirements.txt index beed5456..b7b8c67c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -anthropic langchain-openai langchain-core pytest diff --git a/tests/unit/test_anthropic_model_adapter.py b/tests/unit/test_anthropic_model_adapter.py deleted file mode 100644 index 7ab04e8c..00000000 --- a/tests/unit/test_anthropic_model_adapter.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from dare_framework.context.types import Message -from dare_framework.model.adapters.anthropic_adapter import ( - AnthropicModelAdapter, - _extract_response_text, - _extract_thinking_content, - _extract_tool_calls, - _extract_usage, - _resolve_model_name, - _serialize_system_and_messages, -) -from dare_framework.model.types import GenerateOptions, ModelInput -from dare_framework.tool.types import CapabilityDescriptor, CapabilityType - - -def test_resolve_model_name_prefers_explicit_model_value() -> None: - assert _resolve_model_name(model="claude-sonnet-4-5", env_model="claude-opus-4-1") == "claude-sonnet-4-5" - - -def test_resolve_model_name_uses_env_model_when_explicit_missing() -> None: - assert _resolve_model_name(model=None, env_model="claude-opus-4-1") == "claude-opus-4-1" - - -def test_resolve_model_name_requires_model_source() -> None: - with pytest.raises(ValueError, match="Anthropic model is required"): - _resolve_model_name(model=None, env_model=None) - - -def test_serialize_system_and_messages_preserves_tool_history() -> None: - system_prompt, payload_messages = _serialize_system_and_messages( - [ - Message(role="system", content="You are a helpful assistant."), - Message(role="user", content="Need a filename"), - Message( - role="assistant", - content="I need your confirmation first.", - metadata={ - "tool_calls": [ - { - "id": "call_1", - "name": "ask_user", - "arguments": {"question": "Pick a file name"}, - } - ] - }, - ), - Message( - role="tool", - name="call_1", - content="a.txt", - ), - ] - ) - - assert system_prompt == "You are a helpful assistant." - assert payload_messages[0] == {"role": "user", "content": "Need a filename"} - assert payload_messages[1]["role"] == "assistant" - assert payload_messages[1]["content"][0]["type"] == "text" - assert payload_messages[1]["content"][1] == { - "type": "tool_use", - "id": "call_1", - "name": "ask_user", - "input": {"question": "Pick a file name"}, - } - assert payload_messages[2] == { - "role": "user", - "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "a.txt"}], - } - - -def test_extract_response_fields_from_content_blocks() -> None: - blocks = [ - SimpleNamespace(type="thinking", thinking="internal reasoning"), - SimpleNamespace(type="text", text="final answer"), - SimpleNamespace(type="tool_use", id="toolu_1", name="search", input={"q": "hi"}), - ] - usage = SimpleNamespace( - input_tokens=12, - output_tokens=8, - output_tokens_details=SimpleNamespace(reasoning_tokens=3), - ) - - assert _extract_response_text(blocks) == "final answer" - assert _extract_thinking_content(blocks) == "internal reasoning" - assert _extract_tool_calls(blocks) == [{"id": "toolu_1", "name": "search", "arguments": {"q": "hi"}}] - assert _extract_usage(usage) == { - "prompt_tokens": 12, - "completion_tokens": 8, - "total_tokens": 20, - "reasoning_tokens": 3, - } - - -@pytest.mark.asyncio -async def test_generate_builds_anthropic_payload_and_parses_response() -> None: - calls: list[dict[str, object]] = [] - - class _MessagesAPI: - async def create(self, **kwargs): # noqa: ANN003 - calls.append(kwargs) - return SimpleNamespace( - content=[ - SimpleNamespace(type="thinking", thinking="plan"), - SimpleNamespace(type="tool_use", id="toolu_1", name="search", input={"q": "docs"}), - SimpleNamespace(type="text", text="done"), - ], - usage=SimpleNamespace(input_tokens=7, output_tokens=5), - stop_reason="end_turn", - ) - - class _FakeClient: - def __init__(self) -> None: - self.messages = _MessagesAPI() - - adapter = AnthropicModelAdapter(api_key="test-key", model="claude-opus-4-1") - adapter._client = _FakeClient() # type: ignore[assignment] - - result = await adapter.generate( - ModelInput( - messages=[Message(role="system", content="You are careful."), Message(role="user", content="hello")], - tools=[ - CapabilityDescriptor( - id="search-docs", - type=CapabilityType.TOOL, - name="search", - description="Search docs", - input_schema={"type": "object", "properties": {"q": {"type": "string"}}}, - output_schema={"type": "object"}, - metadata={}, - ) - ], - ), - options=GenerateOptions(max_tokens=128, stop=["STOP"]), - ) - - assert calls - params = calls[0] - assert params["model"] == "claude-opus-4-1" - assert params["system"] == "You are careful." - assert params["stop_sequences"] == ["STOP"] - assert params["max_tokens"] == 128 - assert params["tools"] == [ - { - "name": "search", - "description": "Search docs", - "input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}, - } - ] - - assert result.content == "done" - assert result.thinking_content == "plan" - assert result.tool_calls == [{"id": "toolu_1", "name": "search", "arguments": {"q": "docs"}}] - assert result.usage == {"prompt_tokens": 7, "completion_tokens": 5, "total_tokens": 12} diff --git a/tests/unit/test_client_cli.py b/tests/unit/test_client_cli.py index fb40bb35..2714c94b 100644 --- a/tests/unit/test_client_cli.py +++ b/tests/unit/test_client_cli.py @@ -244,25 +244,6 @@ def test_build_doctor_report_accepts_openai_with_key() -> None: assert "workspace_dir" in payload -def test_build_doctor_report_accepts_anthropic_with_key() -> None: - config = Config.from_dict( - { - "workspace_dir": ".", - "user_dir": ".", - "llm": { - "adapter": "anthropic", - "model": "claude-sonnet-4-5", - "api_key": "dummy", - }, - "mcp_paths": [], - } - ) - payload = build_doctor_report(config=config) - assert payload["llm"]["adapter"] == "anthropic" - assert payload["llm"]["api_key_present"] is True - assert not any("unsupported adapter configured" in item for item in payload["warnings"]) - - @pytest.mark.asyncio async def test_main_doctor_does_not_bootstrap_runtime(monkeypatch, tmp_path) -> None: client_main = importlib.import_module("client.main") diff --git a/tests/unit/test_default_model_adapter_manager.py b/tests/unit/test_default_model_adapter_manager.py index 048db1fc..78b79ee4 100644 --- a/tests/unit/test_default_model_adapter_manager.py +++ b/tests/unit/test_default_model_adapter_manager.py @@ -4,7 +4,7 @@ from dare_framework.agent import BaseAgent from dare_framework.config.types import Config, LLMConfig -from dare_framework.model import AnthropicModelAdapter, OpenAIModelAdapter, OpenRouterModelAdapter +from dare_framework.model import OpenAIModelAdapter, OpenRouterModelAdapter from dare_framework.model.default_model_adapter_manager import DefaultModelAdapterManager @@ -24,15 +24,6 @@ def test_default_manager_returns_openrouter_adapter() -> None: assert adapter.model_name == "openrouter/test" -def test_default_manager_returns_anthropic_adapter() -> None: - manager = DefaultModelAdapterManager() - config = Config(llm=LLMConfig(adapter="anthropic", api_key="test-key", model="claude-sonnet-4-5")) - adapter = manager.load_model_adapter(config=config) - assert isinstance(adapter, AnthropicModelAdapter) - assert adapter.name == "anthropic" - assert adapter.model_name == "claude-sonnet-4-5" - - def test_default_manager_unsupported_adapter_raises() -> None: manager = DefaultModelAdapterManager() config = Config(llm=LLMConfig(adapter="unknown")) From df55503898e82ea93d04f7570a1013e61ca564ca Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 6 Mar 2026 10:21:48 +0800 Subject: [PATCH 09/12] fix(ci): unblock governance-intent-gate and checkpoint defaults facade Fix the newly failing governance intent gate and the latest review comment on checkpoint defaults. Changes: - rewrite dare_framework/checkpoint/defaults.py to remove imports from deleted checkpoint._internal modules - provide in-module legacy-compatible checkpoint symbols so the defaults facade remains importable - add regression test tests/unit/test_checkpoint_defaults.py to lock importable default exports - add missing Intent PR link to docs/features/p0-conformance-gate.md so governance-intent-gate can validate merged intent metadata for active governed docs Verification: - ../../.venv/bin/python -m pytest -q tests/unit/test_checkpoint_defaults.py (passed) - GOVERNANCE_INTENT_GATE_DIFF_RANGE='origin/main...HEAD' GOVERNANCE_INTENT_GATE_PR_STATE_FIXTURE='zts212653/Deterministic-Agent-Runtime-Engine#126=merged,zts212653/Deterministic-Agent-Runtime-Engine#172=merged' ./scripts/ci/check_governance_intent_gate.sh (passed) - ./scripts/ci/check_governance_traceability.sh (passed) - ./scripts/ci/check_governance_evidence_truth.sh (passed) - openspec validate p0-conformance-gate --type change --strict --json --no-interactive (valid) --- dare_framework/checkpoint/defaults.py | 202 +++++++++++++++++++++++-- docs/features/p0-conformance-gate.md | 1 + tests/unit/test_checkpoint_defaults.py | 17 +++ 3 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_checkpoint_defaults.py diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index 10686b81..04bb873c 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -1,15 +1,193 @@ -"""Supported default checkpoint implementations and contributors.""" - -from dare_framework.checkpoint._internal.contributors.session_contributor import ( - SessionContextContributor, - SessionStateContributor, -) -from dare_framework.checkpoint._internal.contributors.stm_contributor import StmContributor -from dare_framework.checkpoint._internal.contributors.workspace_git_contributor import ( - WorkspaceGitContributor, -) -from dare_framework.checkpoint._internal.memory_store import MemoryCheckpointStore -from dare_framework.checkpoint._internal.save_restore import DefaultCheckpointSaveRestore +"""Supported default checkpoint implementations and contributors. + +This module keeps legacy checkpoint symbols importable after checkpoint internals +were consolidated into ``dare_framework.checkpoint.kernel``. +""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any +from uuid import uuid4 + +from dare_framework.context.types import Message + +STM = "stm" +SESSION_STATE = "session_state" +SESSION_CONTEXT = "session_context" +WORKSPACE_FILES = "workspace_files" + + +class MemoryCheckpointStore: + """In-memory checkpoint payload store kept for facade compatibility.""" + + def __init__(self) -> None: + self._store: dict[str, dict[str, Any]] = {} + + def put(self, checkpoint_id: str, payload: dict[str, Any]) -> None: + self._store[checkpoint_id] = dict(payload) + + def get(self, checkpoint_id: str) -> dict[str, Any] | None: + if checkpoint_id not in self._store: + return None + return dict(self._store[checkpoint_id]) + + def delete(self, checkpoint_id: str) -> bool: + if checkpoint_id in self._store: + del self._store[checkpoint_id] + return True + return False + + +def _scope_keys(scope: Any, method_name: str) -> list[str]: + method = getattr(scope, method_name, None) + if callable(method): + values = method() + if isinstance(values, (list, tuple, set)): + return [str(v) for v in values] + if isinstance(scope, (list, tuple, set)): + return [str(v) for v in scope] + return [] + + +class DefaultCheckpointSaveRestore: + """Legacy save/restore coordinator over contributor payload components.""" + + def __init__(self, store: MemoryCheckpointStore, contributors: list[Any]) -> None: + self._store = store + self._contributors = { + str(c.component_key): c + for c in contributors + if getattr(c, "component_key", None) is not None + } + + def save(self, scope: Any, ctx: Any) -> str: + payload: dict[str, Any] = {} + for key in _scope_keys(scope, "keys_for_save"): + contributor = self._contributors.get(key) + if contributor is None: + continue + payload[key] = contributor.serialize(ctx) + checkpoint_id = uuid4().hex[:16] + self._store.put(checkpoint_id, payload) + return checkpoint_id + + def restore(self, checkpoint_id: str, scope: Any, ctx: Any) -> None: + payload = self._store.get(checkpoint_id) + if payload is None: + raise LookupError(f"Checkpoint not found: {checkpoint_id!r}") + for key in _scope_keys(scope, "keys_for_restore"): + if key not in payload: + continue + contributor = self._contributors.get(key) + if contributor is None: + continue + contributor.deserialize_and_apply(payload[key], ctx) + + +class StmContributor: + """Serialize/restore short-term memory messages.""" + + component_key = STM + + def serialize(self, ctx: Any) -> list[dict[str, Any]]: + context = getattr(ctx, "context", None) + if context is None: + return [] + messages = context.stm_get() + return [ + { + "role": m.role, + "content": m.content, + "name": m.name, + "metadata": dict(getattr(m, "metadata", {}) or {}), + } + for m in messages + ] + + def deserialize_and_apply(self, payload: list[Any], ctx: Any) -> None: + context = getattr(ctx, "context", None) + if context is None: + return + context.stm_clear() + for item in payload or []: + if not isinstance(item, dict): + continue + context.stm_add( + Message( + role=item.get("role", "user"), + content=item.get("content", ""), + name=item.get("name"), + metadata=dict(item.get("metadata") or {}), + ) + ) + + +class SessionStateContributor: + """Serialize/restore minimal session-state fields.""" + + component_key = SESSION_STATE + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + state = getattr(ctx, "session_state", None) + if state is None: + return None + try: + return asdict(state) + except Exception: + return { + "current_milestone_idx": getattr(state, "current_milestone_idx", None), + "task_id": getattr(state, "task_id", None), + "run_id": getattr(state, "run_id", None), + } + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + state = getattr(ctx, "session_state", None) + if state is None or not isinstance(payload, dict): + return + if "current_milestone_idx" in payload and hasattr(state, "current_milestone_idx"): + state.current_milestone_idx = payload["current_milestone_idx"] + + +class SessionContextContributor: + """Serialize session context for audit trails (restore intentionally no-op).""" + + component_key = SESSION_CONTEXT + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + session_context = getattr(ctx, "session_context", None) + if session_context is None: + return None + try: + serialized = asdict(session_context) + except Exception: + return { + "session_id": getattr(session_context, "session_id", None), + "task_id": getattr(session_context, "task_id", None), + } + if "config" in serialized and serialized["config"] is not None: + try: + serialized["config"] = asdict(serialized["config"]) + except Exception: + serialized["config"] = None + return serialized + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + # SessionContext is construction-time state; legacy restore path keeps this as no-op. + + +class WorkspaceGitContributor: + """Compatibility no-op contributor after workspace-git checkpoint removal.""" + + component_key = WORKSPACE_FILES + + def serialize(self, ctx: Any) -> dict[str, Any]: + _ = ctx + return {} + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) __all__ = [ "MemoryCheckpointStore", diff --git a/docs/features/p0-conformance-gate.md b/docs/features/p0-conformance-gate.md index e4cb3c9a..e0a4b014 100644 --- a/docs/features/p0-conformance-gate.md +++ b/docs/features/p0-conformance-gate.md @@ -98,6 +98,7 @@ mode: openspec ### Review and Merge Gate Links +- Intent PR: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/172` - Current continuation branch: `codex/p0-conformance-gate` - Current implementation PR: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/172` - Historical PR for task `2.4`: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/130` diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py new file mode 100644 index 00000000..f8c7b6c5 --- /dev/null +++ b/tests/unit/test_checkpoint_defaults.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import importlib + + +def test_checkpoint_defaults_module_exports_are_importable() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + + for symbol in ( + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", + ): + assert hasattr(defaults, symbol), f"missing checkpoint default symbol: {symbol}" From e2f7a8d85e457ecd93d24ffe925b9c367bb40f31 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 6 Mar 2026 11:42:58 +0800 Subject: [PATCH 10/12] fix(checkpoint): preserve session context config in defaults serialization Address the latest PR #188 review comment on SessionContextContributor config loss. Root cause: - SessionContextContributor.serialize() called asdict(session_context) and then attempted asdict(serialized['config']) again. - For dataclass-backed contexts, the first asdict() already converted config to dict, so the second call raised and forced config to None. Changes: - only run asdict() for config when config is non-null and not already a dict - keep existing dict config payload untouched - extend tests/unit/test_checkpoint_defaults.py with a regression test that verifies config survives serialization Verification: - ../../.venv/bin/python -m pytest -q tests/unit/test_checkpoint_defaults.py (2 passed) - GOVERNANCE_INTENT_GATE_DIFF_RANGE='origin/main...HEAD' GOVERNANCE_INTENT_GATE_PR_STATE_FIXTURE='zts212653/Deterministic-Agent-Runtime-Engine#126=merged,zts212653/Deterministic-Agent-Runtime-Engine#172=merged' ./scripts/ci/check_governance_intent_gate.sh (passed) - ./scripts/ci/check_governance_traceability.sh (passed) - ./scripts/ci/check_governance_evidence_truth.sh (passed) --- dare_framework/checkpoint/defaults.py | 5 ++-- tests/unit/test_checkpoint_defaults.py | 35 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index 04bb873c..cf6b6a89 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -165,9 +165,10 @@ def serialize(self, ctx: Any) -> dict[str, Any] | None: "session_id": getattr(session_context, "session_id", None), "task_id": getattr(session_context, "task_id", None), } - if "config" in serialized and serialized["config"] is not None: + config_value = serialized.get("config") + if config_value is not None and not isinstance(config_value, dict): try: - serialized["config"] = asdict(serialized["config"]) + serialized["config"] = asdict(config_value) except Exception: serialized["config"] = None return serialized diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py index f8c7b6c5..017d9edf 100644 --- a/tests/unit/test_checkpoint_defaults.py +++ b/tests/unit/test_checkpoint_defaults.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +from dataclasses import dataclass def test_checkpoint_defaults_module_exports_are_importable() -> None: @@ -15,3 +16,37 @@ def test_checkpoint_defaults_module_exports_are_importable() -> None: "SessionContextContributor", ): assert hasattr(defaults, symbol), f"missing checkpoint default symbol: {symbol}" + + +@dataclass +class _DummyConfig: + model: str + + +@dataclass +class _DummySessionContext: + session_id: str + task_id: str + config: _DummyConfig | None + + +@dataclass +class _DummyCtx: + session_context: _DummySessionContext | None + + +def test_session_context_contributor_preserves_serialized_config_dict() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.SessionContextContributor() + ctx = _DummyCtx( + session_context=_DummySessionContext( + session_id="s-1", + task_id="t-1", + config=_DummyConfig(model="gpt-4.1"), + ) + ) + + payload = contributor.serialize(ctx) + + assert payload is not None + assert payload["config"] == {"model": "gpt-4.1"} From 9ff1dc715c9f4fd4923abc3eec18a0193af44344 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 6 Mar 2026 14:21:33 +0800 Subject: [PATCH 11/12] fix(ci): detect directory selector overlap in failure ownership map Address the latest review comment on selector-overlap coverage in scripts/ci/check_test_failure_ownership.py. Root cause: - overlap detection only tokenized selectors by - directory selectors like were treated as unrelated to descendant node selectors such as - this allowed cross-category ownership overlap to bypass validation Changes: - normalize selectors into path parts + pytest node parts - treat file_or_dir path prefix matches as overlapping when the prefix side has no node suffix - keep existing node-level overlap and parametrized node normalization behavior - add regression test for directory selector overlap in tests/unit/test_check_test_failure_ownership.py Verification: - ../../.venv/bin/python -m pytest -q tests/unit/test_check_test_failure_ownership.py (5 passed) - ../../.venv/bin/python scripts/ci/check_test_failure_ownership.py (passed) - ../../.venv/bin/python scripts/ci/p0_gate.py (PASS) - GOVERNANCE_INTENT_GATE_DIFF_RANGE='origin/main...HEAD' GOVERNANCE_INTENT_GATE_PR_STATE_FIXTURE='zts212653/Deterministic-Agent-Runtime-Engine#126=merged,zts212653/Deterministic-Agent-Runtime-Engine#172=merged' ./scripts/ci/check_governance_intent_gate.sh (passed) - ./scripts/ci/check_governance_traceability.sh (passed) - ./scripts/ci/check_governance_evidence_truth.sh (passed) --- scripts/ci/check_test_failure_ownership.py | 37 +++++++++++++------ .../unit/test_check_test_failure_ownership.py | 11 ++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/ci/check_test_failure_ownership.py b/scripts/ci/check_test_failure_ownership.py index 14cf8067..cabd40e5 100644 --- a/scripts/ci/check_test_failure_ownership.py +++ b/scripts/ci/check_test_failure_ownership.py @@ -13,22 +13,26 @@ from scripts.ci.p0_gate import DEFAULT_CATEGORY_SPECS, CategorySpec -def _normalize_selector_segments(selector: str) -> list[str]: - segments = [segment.strip() for segment in selector.split("::")] - normalized: list[str] = [] - for segment in segments: - if not segment: - continue +def _normalize_selector_parts(selector: str) -> tuple[list[str], list[str]]: + segments = [segment.strip() for segment in selector.split("::") if segment.strip()] + if not segments: + return [], [] + + raw_path = segments[0].replace("\\", "/") + path_parts = [part for part in raw_path.split("/") if part and part != "."] + node_parts = [ # `pytest` parametrization suffixes (`test_x[param]`) are narrower matches # than the base node (`test_x`) and should count as overlapping selectors. - normalized.append(segment.split("[", 1)[0]) - return normalized + segment.split("[", 1)[0] + for segment in segments[1:] + ] + return path_parts, node_parts def _selectors_overlap(left: str, right: str) -> bool: - left_segments = _normalize_selector_segments(left) - right_segments = _normalize_selector_segments(right) - if not left_segments or not right_segments: + left_path, left_nodes = _normalize_selector_parts(left) + right_path, right_nodes = _normalize_selector_parts(right) + if not left_path or not right_path: return False def _is_prefix(prefix: list[str], full: list[str]) -> bool: @@ -36,7 +40,16 @@ def _is_prefix(prefix: list[str], full: list[str]) -> bool: return False return prefix == full[: len(prefix)] - return _is_prefix(left_segments, right_segments) or _is_prefix(right_segments, left_segments) + if left_path == right_path: + return _is_prefix(left_nodes, right_nodes) or _is_prefix(right_nodes, left_nodes) + + # `pytest` accepts file_or_dir selectors; a directory selector overlaps any + # descendant file/node selector under the same subtree. + if _is_prefix(left_path, right_path): + return not left_nodes + if _is_prefix(right_path, left_path): + return not right_nodes + return False def validate_category_specs(specs: list[CategorySpec]) -> list[str]: diff --git a/tests/unit/test_check_test_failure_ownership.py b/tests/unit/test_check_test_failure_ownership.py index b09f6206..84a42d0d 100644 --- a/tests/unit/test_check_test_failure_ownership.py +++ b/tests/unit/test_check_test_failure_ownership.py @@ -53,6 +53,17 @@ def test_validate_category_specs_rejects_overlapping_test_mapping() -> None: assert any("overlapping test selector mapping" in issue for issue in issues) +def test_validate_category_specs_rejects_overlapping_directory_selector_mapping() -> None: + issues = validate_category_specs( + [ + _spec("SECURITY_REGRESSION", tests=["tests/unit"]), + _spec("STEP_EXEC_REGRESSION", tests=["tests/unit/test_a.py::test_x"]), + ] + ) + + assert any("overlapping test selector mapping" in issue for issue in issues) + + def test_validate_category_specs_rejects_missing_owner() -> None: issues = validate_category_specs( [ From 17d086998c0925452530cbbf86b01886ccbd1571 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 6 Mar 2026 16:45:52 +0800 Subject: [PATCH 12/12] fix(ci): handle detail-claim empty change and intent-gate nounset crash Address two governance gate regressions surfaced on PR #188. Traceability gate: - treat Detail Claim Ref existence separately from the referenced OpenSpec Change value - avoid false negatives when the detail claim exists but its change cell is intentionally empty - add regression coverage in test_governance_traceability_gate.py Intent gate: - remove empty-array expansions that can trip set -u as unbound in some shell behaviors - switch changed/governed feature doc collections to newline-delimited strings for portable iteration - add regression assertion that the gate no longer emits unbound-variable errors Validation: - pytest -q tests/unit/test_governance_intent_gate.py - pytest -q tests/unit/test_governance_traceability_gate.py - scripts/ci/check_governance_intent_gate.sh (now deterministic failure without shell crash in no-doc scenario) - scripts/ci/check_governance_traceability.sh --- scripts/ci/check_governance_intent_gate.sh | 30 ++++++++++++------- scripts/ci/check_governance_traceability.sh | 5 ++-- tests/unit/test_governance_intent_gate.py | 1 + .../unit/test_governance_traceability_gate.py | 16 ++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/scripts/ci/check_governance_intent_gate.sh b/scripts/ci/check_governance_intent_gate.sh index 536525de..d8ec5874 100755 --- a/scripts/ci/check_governance_intent_gate.sh +++ b/scripts/ci/check_governance_intent_gate.sh @@ -221,12 +221,17 @@ main() { fi local implementation_changed="false" - local -a changed_feature_docs=() + # Store doc paths as newline-separated strings for set -u portability across + # shell versions where empty array expansion can be treated as unbound. + local changed_feature_docs="" local path while IFS= read -r path; do [[ -z "$path" ]] && continue if [[ "$path" =~ ^docs/features/[^/]+\.md$ ]] && [[ "$path" != "docs/features/README.md" ]]; then - changed_feature_docs+=("$path") + if [[ -n "$changed_feature_docs" ]]; then + changed_feature_docs+=$'\n' + fi + changed_feature_docs+="$path" fi if ! is_governance_only_path "$path"; then implementation_changed="true" @@ -238,14 +243,15 @@ main() { exit 0 fi - if [[ ${#changed_feature_docs[@]} -eq 0 ]]; then + if [[ -z "$changed_feature_docs" ]]; then log "implementation changes detected but PR must update at least one governed feature doc under docs/features/*.md" failures=$((failures + 1)) fi - local -a governed_feature_docs=() + local governed_feature_docs="" local doc frontmatter status normalized_status - for doc in "${changed_feature_docs[@]}"; do + while IFS= read -r doc; do + [[ -z "$doc" ]] && continue if [[ ! -f "$doc" ]]; then log "changed governed feature doc is missing in workspace: $doc" failures=$((failures + 1)) @@ -256,17 +262,21 @@ main() { status="$(trim_quotes "$(frontmatter_scalar "$frontmatter" "status")")" normalized_status="$(normalize_status "$status")" if [[ "$normalized_status" == "active" || "$normalized_status" == "in_review" ]]; then - governed_feature_docs+=("$doc") + if [[ -n "$governed_feature_docs" ]]; then + governed_feature_docs+=$'\n' + fi + governed_feature_docs+="$doc" fi - done + done <<<"$changed_feature_docs" - if [[ ${#governed_feature_docs[@]} -eq 0 ]]; then + if [[ -z "$governed_feature_docs" ]]; then log "implementation changes detected but PR must update at least one governed feature doc (status active/in_review)" failures=$((failures + 1)) fi local intent_pr_url components owner repo pr_number pr_state - for doc in "${governed_feature_docs[@]}"; do + while IFS= read -r doc; do + [[ -z "$doc" ]] && continue intent_pr_url="$(extract_intent_pr_url "$doc")" if [[ -z "$intent_pr_url" ]]; then log "missing Intent PR link in $doc" @@ -294,7 +304,7 @@ main() { fi log "validated merged Intent PR for $doc: $intent_pr_url" - done + done <<<"$governed_feature_docs" if [[ $failures -gt 0 ]]; then log "failed with $failures issue(s)" diff --git a/scripts/ci/check_governance_traceability.sh b/scripts/ci/check_governance_traceability.sh index b5b6beed..2f1c714c 100755 --- a/scripts/ci/check_governance_traceability.sh +++ b/scripts/ci/check_governance_traceability.sh @@ -567,7 +567,7 @@ check_todo_claim_to_openspec_task_mapping() { check_master_todo_slice_mapping_consistency() { local project_file="docs/todos/project_overall_todos.md" local detail_file="docs/todos/agentscope_domain_execution_todos.md" - local line status_field claim_id change_field detail_refs ref detail_change + local line status_field claim_id change_field detail_refs ref detail_change detail_claim_found local detail_claim_map if [[ ! -f "$project_file" || ! -f "$detail_file" ]]; then @@ -598,8 +598,9 @@ check_master_todo_slice_mapping_consistency() { while IFS= read -r ref; do [[ -z "$ref" ]] && continue + detail_claim_found="$(awk -F'|' -v target="$ref" '$1 == target {print "1"; exit}' "$detail_claim_map")" detail_change="$(awk -F'|' -v target="$ref" '$1 == target {print $2; exit}' "$detail_claim_map")" - if [[ -z "$detail_change" ]]; then + if [[ -z "$detail_claim_found" ]]; then log "missing Detail Claim Ref mapping in $project_file: $ref" failures=$((failures + 1)) continue diff --git a/tests/unit/test_governance_intent_gate.py b/tests/unit/test_governance_intent_gate.py index 50ac15b7..ea051540 100644 --- a/tests/unit/test_governance_intent_gate.py +++ b/tests/unit/test_governance_intent_gate.py @@ -103,6 +103,7 @@ def test_implementation_changes_require_governed_feature_doc_update(self) -> Non self.assertNotEqual(result.returncode, 0) self.assertIn("must update at least one governed feature doc", result.stdout) + self.assertNotIn("unbound variable", result.stderr) def test_implementation_changes_fail_when_intent_pr_not_merged(self) -> None: result = self._run_gate( diff --git a/tests/unit/test_governance_traceability_gate.py b/tests/unit/test_governance_traceability_gate.py index c69cd4fe..fe818e40 100644 --- a/tests/unit/test_governance_traceability_gate.py +++ b/tests/unit/test_governance_traceability_gate.py @@ -481,6 +481,22 @@ def mutate(root: Path) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("missing Detail Claim Ref mapping in docs/todos/project_overall_todos.md", result.stdout) + def test_gate_accepts_detail_claim_ref_with_empty_change_cell(self) -> None: + def mutate(root: Path) -> None: + detail_doc = root / "docs" / "todos" / "agentscope_domain_execution_todos.md" + detail_doc.write_text( + detail_doc.read_text(encoding="utf-8").replace( + "`demo-change` | `CLM-AG1`", + " | `CLM-AG1`", + ), + encoding="utf-8", + ) + + result = self._run_gate(mutate) + + self.assertEqual(result.returncode, 0) + self.assertIn("passed", result.stdout) + def test_gate_accepts_date_prefixed_archived_change_tasks(self) -> None: def mutate(root: Path) -> None: feature_doc = root / "docs" / "features" / "demo-change.md"