1+ """Top-level entry point for agentic review, used by worker.py."""
2+ from __future__ import annotations
3+
4+ import json
5+ import os
6+ import re
7+ import time
8+ from dataclasses import asdict , dataclass
9+ from pathlib import Path
10+ from typing import Any
11+
12+ from biz .agent .llm_adapter import LLMAdapter
13+ from biz .agent .prompts import load_prompt
14+ from biz .agent .repo_syncer import LocalRepoSyncer
15+ from biz .agent .runner import AgentRunner
16+ from biz .agent .tools import register_default_tools
17+ from biz .agent .tool_registry import ToolRegistry
18+ from biz .llm .factory import Factory
19+ from biz .utils .code_reviewer import CodeReviewer
20+ from biz .utils .log import logger
21+ from biz .utils .im import notifier
22+
23+
24+ # Same regex CodeReviewer.parse_review_score uses; reused as a sanity gate
25+ # so we don't post the agent's tool-selection reasoning as a "review".
26+ _REVIEW_SCORE_RE = re .compile (r"总分[::]\s*(\d+)分?" )
27+
28+
29+ def _slugify_repo_key (provider : str , project : str ) -> str :
30+ """Build a stable cache key for a project."""
31+ return f"{ provider } _{ project } " .replace ("/" , "_" ).replace (" " , "_" )
32+
33+
34+ def _parse_csv_env (name : str , default : list [str ]) -> list [str ]:
35+ """Read a comma-separated env var, fall back to `default` if unset/empty."""
36+ raw = os .getenv (name , "" ).strip ()
37+ if not raw :
38+ return list (default )
39+ return [item .strip () for item in raw .split ("," ) if item .strip ()] or list (default )
40+
41+
42+ def _looks_like_review (text : str | None ) -> bool :
43+ """Heuristic: a well-formed agentic review must include the `总分:XX分` marker."""
44+ if not text :
45+ return False
46+ return bool (_REVIEW_SCORE_RE .search (text ))
47+
48+
49+ @dataclass
50+ class ReviewLog :
51+ event : str
52+ project : str
53+ ref : str
54+ strategy : str
55+ iterations : int
56+ total_tokens_est : int
57+ duration_ms : int
58+ review_result_length : int
59+ score : int
60+ degraded : bool
61+ tool_calls : list [dict ]
62+
63+
64+ def _estimate_tokens (messages : list [dict ]) -> int :
65+ """Rough token estimate: sum of len(content)//4 over assistant messages."""
66+ total = 0
67+ for m in messages :
68+ if m .get ("role" ) != "assistant" :
69+ continue
70+ content = m .get ("content" ) or ""
71+ if isinstance (content , str ):
72+ total += len (content ) // 4
73+ return total
74+
75+
76+ def _collect_tool_calls (messages : list [dict ]) -> list [dict ]:
77+ """Flatten tool_calls from assistant messages for structured logging."""
78+ calls : list [dict ] = []
79+ for m in messages :
80+ if m .get ("role" ) != "assistant" :
81+ continue
82+ for call in m .get ("tool_calls" ) or []:
83+ calls .append (call )
84+ return calls
85+
86+
87+ class AgenticReviewer :
88+ def __init__ (
89+ self ,
90+ * ,
91+ repo_url : str ,
92+ repo_key : str ,
93+ ref : str ,
94+ cache_root : Path | str ,
95+ adapter : LLMAdapter | None = None ,
96+ max_iterations : int = 20 ,
97+ total_token_cap : int = 80_000 ,
98+ ) -> None :
99+ self .repo_url = repo_url
100+ self .repo_key = repo_key
101+ self .ref = ref
102+ self .cache_root = Path (cache_root )
103+ self .adapter = adapter
104+ self .max_iterations = max_iterations
105+ self .total_token_cap = total_token_cap
106+
107+ def _build_adapter (self ) -> LLMAdapter :
108+ if self .adapter is not None :
109+ return self .adapter
110+ client = Factory ().getClient ()
111+ return LLMAdapter (client )
112+
113+ def _build_registry (self , repo_root : Path ) -> ToolRegistry :
114+ registry = ToolRegistry ()
115+ from biz .agent .tools .run_command import (
116+ DEFAULT_ALLOWLIST ,
117+ DEFAULT_BLOCKLIST ,
118+ )
119+ allow = _parse_csv_env ("AGENT_SHELL_ALLOWLIST" , DEFAULT_ALLOWLIST )
120+ block = _parse_csv_env ("AGENT_SHELL_BLOCKLIST" , DEFAULT_BLOCKLIST )
121+ register_default_tools (registry , repo_root , allowlist = allow , blocklist = block )
122+ return registry
123+
124+ def review (self , diffs_text : str , commits_text : str ) -> str :
125+ start = time .monotonic ()
126+ # 1. Sync repo locally.
127+ try :
128+ syncer = LocalRepoSyncer (cache_root = self .cache_root )
129+ repo_root = syncer .sync_to (url = self .repo_url , key = self .repo_key , ref = self .ref )
130+ except Exception as e :
131+ logger .error ("agentic repo sync failed, degrading: %s" , e )
132+ notifier .send_notification (content = f"[agentic] repo sync failed: { e } ; falling back to diff_only" )
133+ return CodeReviewer ().review_and_strip_code (diffs_text , commits_text )
134+
135+ # 2. Build adapter, registry, runner.
136+ adapter = self ._build_adapter ()
137+ registry = self ._build_registry (repo_root )
138+ runner = AgentRunner (
139+ adapter = adapter ,
140+ registry = registry ,
141+ max_iterations = self .max_iterations ,
142+ total_token_cap = self .total_token_cap ,
143+ )
144+
145+ # 3. Build initial messages from prompt template.
146+ prompts = load_prompt ("agentic_code_review_prompt" , style = os .getenv ("REVIEW_STYLE" , "professional" ))
147+ user_content = prompts ["user_message" ]["content" ].format (
148+ diffs_text = diffs_text ,
149+ commits_text = commits_text ,
150+ repo_root = str (repo_root ),
151+ )
152+ messages = [prompts ["system_message" ], {"role" : "user" , "content" : user_content }]
153+
154+ # 4. Run the agent loop with soft-degrade; collect metadata for logging.
155+ run_meta : dict [str , Any ] = {}
156+ result : str
157+ degraded = False
158+ try :
159+ result = runner .run (messages , out = run_meta )
160+ except Exception as e :
161+ logger .error ("agentic run failed, degrading to diff_only: %s" , e )
162+ notifier .send_notification (content = f"[agentic] run failed: { e } ; falling back to diff_only" )
163+ degraded = True
164+ result = CodeReviewer ().review_and_strip_code (diffs_text , commits_text )
165+
166+ # 4b. Defense-in-depth: if the agent's text doesn't look like a review
167+ # (missing the `总分:XX分` marker), treat the leak as a failure and
168+ # fall back to diff_only. Otherwise the agent's tool-selection
169+ # reasoning — e.g. "AST query doesn't find references, let me check
170+ # the openspec folder" — would be posted to GitLab as a "review".
171+ if not degraded and not _looks_like_review (result ):
172+ logger .warning (
173+ "agent output missing 总分 marker (len=%d), degrading to diff_only" ,
174+ len (result or "" ),
175+ )
176+ notifier .send_notification (
177+ content = "[agentic] output missing 总分 marker; falling back to diff_only"
178+ )
179+ degraded = True
180+ result = CodeReviewer ().review_and_strip_code (diffs_text , commits_text )
181+
182+ # 5. Emit structured per-review log line.
183+ run_messages = run_meta .get ("messages" , messages )
184+ log_entry = ReviewLog (
185+ event = "agentic_review" ,
186+ project = self .repo_key ,
187+ ref = self .ref ,
188+ strategy = "agentic" ,
189+ iterations = run_meta .get ("iterations" , 0 ),
190+ total_tokens_est = _estimate_tokens (run_messages ),
191+ duration_ms = int ((time .monotonic () - start ) * 1000 ),
192+ review_result_length = len (result ),
193+ score = CodeReviewer .parse_review_score (review_text = result ),
194+ degraded = degraded ,
195+ tool_calls = _collect_tool_calls (run_messages ),
196+ )
197+ logger .info (json .dumps (asdict (log_entry ), ensure_ascii = False ))
198+ return result
0 commit comments