diff --git a/src/agent_tools/core/models/workflow.py b/src/agent_tools/core/models/workflow.py index e01ff03..ba6615e 100644 --- a/src/agent_tools/core/models/workflow.py +++ b/src/agent_tools/core/models/workflow.py @@ -12,9 +12,7 @@ class Result: workflow: str = "" next_step: str = "" # Semantic label for the next logical activity (e.g., 'resolve_conflicts') resume_point: str = "" # Machine-readable string to pass back to the orchestrator (e.g., '--point current_rebase') - instruction: str = "" # Specific, actionable instructions for the LLM agent - constraints: list[str] = field(default_factory=list) # Explicit prohibitions for the LLM agent - strict_protocol: bool = True # If True, the LLM must strictly follow the workflow and never bypass it + instruction: str = "" # Specific, actionable instructions and explicit prohibitions for the LLM agent details: dict[str, Any] = field(default_factory=dict) def to_json(self) -> str: diff --git a/src/agent_tools/core/orchestrators/gh_pr_create.py b/src/agent_tools/core/orchestrators/gh_pr_create.py index d5db16a..c4f73c2 100644 --- a/src/agent_tools/core/orchestrators/gh_pr_create.py +++ b/src/agent_tools/core/orchestrators/gh_pr_create.py @@ -12,7 +12,8 @@ ) from agent_tools.infrastructure.config.manager import ( - get_full_commit_rules, + get_commit_subject_regex, + get_commit_body_wrap_length, ) from agent_tools.infrastructure.clients.github.client import run_gh @@ -114,13 +115,12 @@ def _handle_sense() -> Result: "【STRICT PROTOCOL / 严格协议】您当前处于受控工作流中。禁止跳过步骤、禁止执行任何未授权的裸命令。\n" "【ACTION】\n" "1. Review 'commits' in details.\n" - "2. Synthesize a professional PR title (Conventional Commit style) and a structured markdown body.\n" + "2. Synthesize a professional PR title for `draft_json_str` following the 'details.json_format' layout.\n" + " - 'title': A professional pr commit subject. 【STRICT】MUST satisfy 'details.subject_regex'.\n" + " - 'body': The detailed body should explain the 'why' and 'how' (not just 'what'), especially for complex logic changes. 【STRICT】single line max `details.body_wrap_length` chars.\n" "3. Mention any relevant issue numbers if known.\n" - "4. Call 'gh_pr_create_flow' with point='create' and your 'draft_json_str', formatted according to 'details.json_format'." + "4. Call 'gh_pr_create_flow' with point='create' and your 'draft_json_str', formatted according to 'details.json_format'.\n" ), - constraints=[ - "Each commit message MUST follow Conventional Commits and `commit_rules`.", - ], details={ "owner": repo_info.owner, "repo": repo_info.repo, @@ -131,7 +131,8 @@ def _handle_sense() -> Result: "title": "feat(core): add JSON schema to results", "body": "## Summary\n- Added json_format to handoff details.\n\n## Test Plan\n- Verified via pytest.", }, - "commit_rules": get_full_commit_rules(), + "subject_regex": get_commit_subject_regex(), + "body_wrap_length": get_commit_body_wrap_length(), }, ) diff --git a/src/agent_tools/core/orchestrators/gh_pr_merge.py b/src/agent_tools/core/orchestrators/gh_pr_merge.py index 09d874f..2573d45 100644 --- a/src/agent_tools/core/orchestrators/gh_pr_merge.py +++ b/src/agent_tools/core/orchestrators/gh_pr_merge.py @@ -10,7 +10,10 @@ run_git, ) from agent_tools.infrastructure.clients.github.client import run_gh -from agent_tools.infrastructure.config.manager import get_full_commit_rules +from agent_tools.infrastructure.config.manager import ( + get_commit_subject_regex, + get_commit_body_wrap_length, +) logger = logging.getLogger(__name__) WORKFLOW = "gh_pr_merge" @@ -166,20 +169,20 @@ def _handle_sense() -> Result: instruction=( "【STRICT PROTOCOL / 严格协议】您当前处于受控工作流中。禁止跳过步骤、禁止执行任何未授权的裸命令。\n" "【ACTION】\n" - "1. Review PR metadata in details.\n" - "2. Synthesize a professional squash commit message following Conventional Commits.\n" - "3. Call 'gh_pr_merge_flow' with point='merge' and your 'override_json_str', formatted according to 'details.json_format'." + "1. Review PR metadata in `details.pr`.\n" + "2. Synthesize a professional squash commit message for `override_json_str` following the 'details.json_format' layout.\n" + " - 'title': A professional squash commit subject.【STRICT】MUST satisfy 'details.subject_regex'.\n" + " - 'body': The detailed body should explain the 'why' and 'how' (not just 'what'), especially for complex logic changes. 【STRICT】single line max `details.body_wrap_length` chars.\n" + "3. Call 'gh_pr_merge_flow' with point='merge' and your 'override_json_str'.\n" ), - constraints=[ - "Each commit message MUST follow Conventional Commits and `commit_rules`.", - ], details={ "pr": pr_data, "json_format": { "title": "feat(core): merge json format logic", - "body": "Detailed summary of PR changes...", + "body": "- Detailed summary of PR changes...", }, - "commit_rules": get_full_commit_rules(), + "subject_regex": get_commit_subject_regex(), + "body_wrap_length": get_commit_body_wrap_length(), }, ) @@ -198,11 +201,11 @@ def _handle_merge(override_json_str: str) -> Result: ) # Validate regex - rules = get_full_commit_rules() - if body and title and not re.match(rules["message_regex"], title): + subject_regex = get_commit_subject_regex() + if not body or not title or not re.match(subject_regex, title): return Result( status="error", - message=f"Title '{title}' violates commit policy: {rules['message_regex']}", + message=f"Title '{title}' violates commit policy: {subject_regex}", workflow=WORKFLOW, ) diff --git a/src/agent_tools/core/orchestrators/git_commit.py b/src/agent_tools/core/orchestrators/git_commit.py index 87c0c96..711b897 100644 --- a/src/agent_tools/core/orchestrators/git_commit.py +++ b/src/agent_tools/core/orchestrators/git_commit.py @@ -11,9 +11,11 @@ ) from agent_tools.infrastructure.config.manager import ( get_allow_direct_actions_to_protected, - get_full_commit_rules, + get_commit_body_wrap_length, + get_commit_subject_regex, get_protected_branches, - get_separation_rules, + get_commit_grouping_signals, + get_commit_max_groups, ) logger = logging.getLogger(__name__) @@ -79,18 +81,20 @@ def _handle_sense() -> Result: next_step="BUILD_COMMIT_PLAN", resume_point="commit", instruction=( - "【STRICT PROTOCOL / 严格协议】您当前处于受控工作流中。禁止跳过步骤、禁止执行任何未授权的裸命令。\n" + "【STRICT PROTOCOL / 严格协议】您当前处于受控工作流中。禁止直接提交,禁止绕过分析步骤。\n" "【ACTION】\n" "1. Review 'staged' vs 'unstaged' files in details.\n" - "2. Decide which files should be committed together based on 'separation_rules'.\n" - "3. Draft commit messages following Conventional Commits.\n" - "4. Call 'git_commit_flow' with point='commit' and your 'plan_json_str', formatted according to 'details.json_format'.\n" - "【SAFETY】Check 'risk_files' carefully. NEVER commit secrets or large binaries accidentally." + "2. Group files into logical commits (max: 'details.max_groups') using these signals: 'details.grouping_signals'.\n" + "3. Synthesize 'plan_json_str' following the 'details.json_format' layout, using these field specs for each entry in 'commits':\n" + " - 'files': Array of file paths to be committed together.\n" + " - 'message': A structured Git message. \n" + " * Subject (1st line): 【STRICT】MUST satisfy 'details.subject_regex'.\n" + " * Detail body (Add a blank line after the subject): " + " * The detailed body should explain the 'why' and 'how' (not just 'what'), especially for complex logic changes. 【STRICT】single line max `details.body_wrap_length` chars.\n" + "4. Call 'git_commit_flow' with point='commit' and your 'plan_json_str'.\n" + "【CONSTRAINTS】\n" + "- Do NOT include files from 'risk_files' unless specifically authorized.\n" ), - constraints=[ - "Do NOT commit files listed in 'risk_files' unless explicitly intended.", - "Each commit message MUST follow Conventional Commits and `commit_rules`.", - ], details={ "staged_files": staged, "unstaged_files": unstaged, @@ -100,12 +104,14 @@ def _handle_sense() -> Result: "commits": [ { "files": ["file1.py", "file2.py"], - "message": "feat(scope): short description\n\nDetailed body...", + "message": "feat(scope): short description\n\n- Detailed body...", } ] }, - "commit_rules": get_full_commit_rules(), - "separation_rules": get_separation_rules(), + "subject_regex": get_commit_subject_regex, + "body_wrap_length": get_commit_body_wrap_length(), + "grouping_signals": get_commit_grouping_signals(), + "max_groups": get_commit_max_groups(), "branch_info": asdict(get_branch_context()), }, ) diff --git a/src/agent_tools/core/orchestrators/git_release.py b/src/agent_tools/core/orchestrators/git_release.py index b53d61f..0b1eb79 100644 --- a/src/agent_tools/core/orchestrators/git_release.py +++ b/src/agent_tools/core/orchestrators/git_release.py @@ -13,8 +13,8 @@ run_git, ) from agent_tools.infrastructure.config.manager import ( - get_full_commit_rules, get_release_tag_regex, + get_commit_body_wrap_length, get_protected_branches, get_allow_direct_actions_to_protected, ) @@ -125,13 +125,14 @@ def _handle_sense() -> Result: "3. If version bump is needed (【PAUSE】You MUST propose the new version and its rationale, then WAIT for USER approval before proceeding):\n" f" - **IF PROTECTED (is_protected={is_protected})**: Use 'gh_pr_create_flow' for a version PR. IMPORTANT: Wait for CI checks, then merge using 'gh_pr_merge_flow'.\n" f" - **ELSE**: Update and commit directly using 'git_commit_flow'.\n" - "4. Finalize with 'git_release_flow' (point='release'), providing a structured message with descriptive title and categorized changes (Features, Bug Fixes, Refactors)." + "4. Finalize with 'git_release_flow' (point='release'), with its 'tag_json_str' following the 'details.json_format'.\n" + " - 'name': The chosen version string. 【STRICT】MUST match 'details.tag_regex'.\n" + " - 'message': Categorized release notes. MUST follow the layout in 'details.json_format.message'.\n" + " * The field MUST include a header and categorized bullet points (Features, Bug Fixes, etc.).\n" + " * The field single line max `details.body_wrap_length` chars.\n" + "【CONSTRAINTS】\n" + "- Do NOT proceed if version files and git history are out of sync." ), - constraints=[ - "Tag MUST match the regex in details.", - "Each commit message MUST follow Conventional Commits and `commit_rules`.", - "Do NOT proceed if version files and git history are out of sync.", - ], details={ "latest_tag": latest_tag, "commits": [asdict(c) for c in commits], @@ -139,14 +140,14 @@ def _handle_sense() -> Result: "json_format": { "name": "v1.2.3", "message": ( - "v1.2.3: Descriptive Title Summary\n\n" + "Descriptive Title Summary\n\n" "### 🚀 Features\n- List key new features here.\n\n" "### 🐛 Bug Fixes\n- List important bug fixes here.\n\n" "### ⚙️ Refactors\n- List major internal improvements here." ), }, "tag_regex": get_release_tag_regex(), - "commit_rules": get_full_commit_rules(), + "body_wrap_length": get_commit_body_wrap_length(), "branch_info": asdict(get_branch_context()), }, ) diff --git a/src/agent_tools/core/orchestrators/git_sync.py b/src/agent_tools/core/orchestrators/git_sync.py index 3680b9d..01a668a 100644 --- a/src/agent_tools/core/orchestrators/git_sync.py +++ b/src/agent_tools/core/orchestrators/git_sync.py @@ -60,14 +60,14 @@ def _pause_for_conflict(current_point: str) -> Result: next_step="RESOLVE_CONFLICTS", resume_point=current_point, instruction=( + "【ACTION】\n" f"1. Resolve conflicts in: {', '.join(files)}\n" "2. Run 'git add ' for all resolved files.\n" - f"3. Resume by calling 'git_sync_flow' with point='{current_point}'." + f"3. Resume by calling 'git_sync_flow' with point='{current_point}'.\n" + "【CONSTRAINTS】\n" + "- Do NOT run 'git commit' during rebase." + "- Do NOT run 'git rebase --continue' manually; the tool handles it." ), - constraints=[ - "Do NOT run 'git commit' during rebase.", - "Do NOT run 'git rebase --continue' manually; the tool handles it.", - ], details={"conflicted_files": files}, ) diff --git a/src/agent_tools/infrastructure/clients/git/commit.py b/src/agent_tools/infrastructure/clients/git/commit.py index 4f40ca3..bc3cc55 100644 --- a/src/agent_tools/infrastructure/clients/git/commit.py +++ b/src/agent_tools/infrastructure/clients/git/commit.py @@ -4,8 +4,8 @@ get_commit_allowed_types, get_commit_body_wrap_length, get_commit_max_groups, - get_commit_message_regex, get_commit_subject_max_length, + get_commit_subject_regex, load_rules, ) @@ -28,7 +28,7 @@ def validate_plan(plan: dict, rules: dict) -> tuple[bool, str]: ) allowed_types = get_commit_allowed_types() - regex_pattern = get_commit_message_regex() + regex_pattern = get_commit_subject_regex() subject_max = get_commit_subject_max_length() body_wrap = get_commit_body_wrap_length() @@ -58,11 +58,13 @@ def validate_plan(plan: dict, rules: dict) -> tuple[bool, str]: # --- Allowed types check (subject only) --- if allowed_types: - msg_type = subject.split(":")[0].split("(")[0] + # Handle cases like "feat(scope)!: ..." or "feat!: ..." + prefix = subject.split(":")[0] + msg_type = prefix.split("(")[0].rstrip("!") if msg_type not in allowed_types: return ( False, - f"Commit message at index {idx} uses disallowed type '{msg_type}'.", + f"Commit message at index {idx} uses disallowed type '{msg_type}'. (Allowed: {', '.join(allowed_types)})", ) # --- Body wrap length check --- diff --git a/src/agent_tools/infrastructure/config/manager.py b/src/agent_tools/infrastructure/config/manager.py index 5eb3908..42ddcf3 100644 --- a/src/agent_tools/infrastructure/config/manager.py +++ b/src/agent_tools/infrastructure/config/manager.py @@ -1,5 +1,6 @@ import functools import json +import re import logging import os from pathlib import Path @@ -120,9 +121,20 @@ def get_commit_allowed_types() -> list[str]: return cast(list[str], rules["git"]["commit"]["allowed_types"]) -def get_commit_message_regex() -> str: - rules = load_rules() - return cast(str, rules["git"]["commit"]["message_regex"]) +def get_commit_subject_regex() -> str: + """Automatically generate regex from 'allowed_types' and 'subject_max_length' (DRY)""" + allowed_types = get_commit_allowed_types() + max_len = get_commit_subject_max_length() + + # Pattern components: + # 1. Start with one of the allowed types + # 2. Optional scope in parentheses (e.g., '(core)') + # 3. Optional breaking change marker '!' + # 4. Mandatory colon and space ': ' + # 5. Total length limit using positive lookahead + # This ensures the entire subject (prefix + scope + description) is within max_len + types_pattern = "|".join(re.escape(t) for t in allowed_types) + return rf"^(?=.{{1,{max_len}}}$)({types_pattern})(\([a-z0-9._\-]+\))?(!)?:\s.+$" def get_commit_subject_max_length() -> int: @@ -163,19 +175,3 @@ def get_allow_direct_actions_to_protected() -> bool: def get_release_tag_regex() -> str: rules = load_rules() return cast(str, rules["git"]["release"]["tag_regex"]) - - -def get_full_commit_rules() -> dict[str, Any]: - return { - "allowed_types": get_commit_allowed_types(), - "message_regex": get_commit_message_regex(), - "subject_max_length": get_commit_subject_max_length(), - "body_wrap_length": get_commit_body_wrap_length(), - } - - -def get_separation_rules() -> dict[str, Any]: - return { - "grouping_signals": get_commit_grouping_signals(), - "max_groups": get_commit_max_groups(), - } diff --git a/src/agent_tools/infrastructure/config/resources/rules.yaml b/src/agent_tools/infrastructure/config/resources/rules.yaml index d729536..70e8edd 100644 --- a/src/agent_tools/infrastructure/config/resources/rules.yaml +++ b/src/agent_tools/infrastructure/config/resources/rules.yaml @@ -19,11 +19,8 @@ git: - revert # Message formatting constraints - subject_max_length: 72 - body_wrap_length: 80 - - # Required regex pattern for commit subjects - message_regex: "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\\([a-z0-9._\\-]+\\)(!)?: .{1,72}$" + subject_max_length: 85 + body_wrap_length: 90 # Grouping strategies when generating a Smart Commit Plan max_groups: 8 diff --git a/src/agent_tools/infrastructure/config/resources/schema.json b/src/agent_tools/infrastructure/config/resources/schema.json index 875c76b..80d9e21 100644 --- a/src/agent_tools/infrastructure/config/resources/schema.json +++ b/src/agent_tools/infrastructure/config/resources/schema.json @@ -27,13 +27,13 @@ "commit": { "type": "object", "additionalProperties": false, - "required": ["allowed_types", "message_regex", "subject_max_length", "body_wrap_length", "grouping_signals", "max_groups"], + "required": ["allowed_types", "subject_max_length", "body_wrap_length", "grouping_signals", "max_groups"], "properties": { "allowed_types": { "type": "array", "items": { "type": "string" } }, - "message_regex": { + "subject_regex": { "type": "string" }, "subject_max_length": { "type": "integer" }, diff --git a/tests/test_config.py b/tests/test_config.py index 0b6c2da..2b406cc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,11 +9,10 @@ get_commit_body_wrap_length, get_commit_grouping_signals, get_commit_max_groups, - get_commit_message_regex, + get_commit_subject_regex, get_commit_subject_max_length, get_diff_max_lines_per_file, get_diff_max_total_lines, - get_full_commit_rules, get_protected_branches, get_release_tag_regex, load_rules, @@ -55,7 +54,7 @@ def test_load_rules_minimal_override(tmp_path, monkeypatch): # 1. 确认覆盖生效 assert rules["git"]["commit"]["max_groups"] == 999 # 2. 确认默认值透传生效 (来自内部 base rules.yaml) - assert rules["git"]["commit"]["subject_max_length"] == 72 + assert rules["git"]["commit"]["subject_max_length"] == 85 assert "main" in rules["git"]["safety"]["protected_branches"] @@ -74,7 +73,6 @@ def test_validate_rules_additional_properties(caplog): }, "commit": { "allowed_types": [], - "message_regex": "", "subject_max_length": 0, "body_wrap_length": 0, "grouping_signals": [], @@ -103,7 +101,6 @@ def test_validate_rules_missing_required_properties(caplog): # 缺少 safety 字段 "commit": { "allowed_types": [], - "message_regex": "", "subject_max_length": 0, "body_wrap_length": 0, "grouping_signals": [], @@ -144,7 +141,7 @@ def test_all_getters_return_typed_values(): assert isinstance(get_protected_branches(), list) assert isinstance(get_commit_allowed_types(), list) - assert isinstance(get_commit_message_regex(), str) + assert isinstance(get_commit_subject_regex(), str) assert isinstance(get_commit_subject_max_length(), int) assert isinstance(get_commit_body_wrap_length(), int) assert isinstance(get_commit_grouping_signals(), list) @@ -153,4 +150,3 @@ def test_all_getters_return_typed_values(): assert isinstance(get_diff_max_total_lines(), int) assert isinstance(get_allow_direct_actions_to_protected(), bool) assert isinstance(get_release_tag_regex(), str) - assert isinstance(get_full_commit_rules(), dict)