* [RFC] devtools: add tool calling support to review-patch.py
@ 2026-06-09 18:26 Aaron Conole
2026-06-15 11:34 ` David Marchand
` (2 more replies)
0 siblings, 3 replies; 6+ messages in thread
From: Aaron Conole @ 2026-06-09 18:26 UTC (permalink / raw)
To: dev; +Cc: Stephen Hemminger, David Marchand
Add an iterative tool-use loop to review-patch.py for the Anthropic
and OpenAI providers. The reviewer can now look up additional context
from the DPDK source tree when the patch alone is insufficient,
rather than having to guess at surrounding code, API contracts, or
function signatures.
Tool calling is enabled by default with a limit of 10 rounds. Pass
'--tool-rounds 0' to disable it and restore the previous single-shot
behavior. The round limit prevents runaway cost on large patches
that when reached will force the model to deliver a final judgement.
Initial tool set:
- grep Searches for regex across the file system with
optional path restrictions and case-insensitive
matches.
- file_read Line range read of a specific path.
Both tools are limited to the repository root to prevent path
traversal. Path outputs are relative to the repo root.
The system prompt is extended when tool calling is active to
encourage the model to use tools only when genuinely needed,
keeping unnecessary round trips and token costs under control
and to a minimum.
Internally, _common.py gains send_request_raw() (returning the
raw response dict) so the tool-calling loops can inspect
stop_reason / finish_reason before extracting text.
Signed-off-by: Aaron Conole <aconole@redhat.com>
---
devtools/ai/_common.py | 66 ++++-
devtools/ai/review-patch.py | 552 +++++++++++++++++++++++++++++++++++-
2 files changed, 602 insertions(+), 16 deletions(-)
diff --git a/devtools/ai/_common.py b/devtools/ai/_common.py
index 69982cbda5..e9fb25557b 100644
--- a/devtools/ai/_common.py
+++ b/devtools/ai/_common.py
@@ -121,7 +121,8 @@ def add_token_args(parser: argparse.ArgumentParser) -> None:
def print_token_summary(
usage: TokenUsage, provider: str, model: str, show: bool
) -> None:
- """Print token usage summary to stderr if requested and any calls were made."""
+ """Print token usage summary to stderr if requested and any calls were
+ made."""
if not show or usage.api_calls == 0:
return
print("", file=sys.stderr)
@@ -173,13 +174,15 @@ def _extract_usage(provider: str, result: dict[str, Any]) -> TokenUsage:
def _extract_text(provider: str, result: dict[str, Any]) -> str:
- """Extract response text from a provider response. Calls error() on failure."""
+ """Extract response text from a provider response. Calls error() on
+ failure."""
if "error" in result:
error(f"API error: {result['error'].get('message', result)}")
if provider == "anthropic":
content = result.get("content", [])
return "".join(
- block.get("text", "") for block in content if block.get("type") == "text"
+ block.get("text", "") for block in content
+ if block.get("type") == "text"
)
if provider == "google":
candidates = result.get("candidates", [])
@@ -200,13 +203,14 @@ def _print_verbose_usage(usage: TokenUsage) -> None:
print(f"Input tokens: {usage.input_tokens:,}", file=sys.stderr)
print(f"Output tokens: {usage.output_tokens:,}", file=sys.stderr)
if usage.cache_creation_tokens:
- print(f"Cache creation: {usage.cache_creation_tokens:,}", file=sys.stderr)
+ print(f"Cache creation: {usage.cache_creation_tokens:,}",
+ file=sys.stderr)
if usage.cache_read_tokens:
print(f"Cache read: {usage.cache_read_tokens:,}", file=sys.stderr)
print("===================", file=sys.stderr)
-def send_request(
+def _send_http_raw(
provider: str,
api_key: str,
model: str,
@@ -214,13 +218,9 @@ def send_request(
*,
timeout: int = 120,
verbose: bool = False,
-) -> tuple[str, TokenUsage]:
- """Send a prebuilt request to a provider and return (response_text, usage).
-
- The caller assembles the provider-specific request body via its own
- build_*_request helpers (the prompts differ per script). This function
- handles transport, error reporting, and token-usage extraction.
- """
+) -> tuple[dict[str, Any], TokenUsage]:
+ """Shared HTTP transport layer. Returns (response_dict, usage). Calls
+ error() on failure."""
url, headers = _build_request_meta(provider, api_key, model)
body = json.dumps(request_data).encode("utf-8")
req = Request(url, data=body, headers=headers)
@@ -243,4 +243,46 @@ def send_request(
usage = _extract_usage(provider, result)
if verbose:
_print_verbose_usage(usage)
+ return result, usage
+
+
+def send_request(
+ provider: str,
+ api_key: str,
+ model: str,
+ request_data: dict[str, Any],
+ *,
+ timeout: int = 120,
+ verbose: bool = False,
+) -> tuple[str, TokenUsage]:
+ """Send a prebuilt request to a provider and return (response_text, usage).
+
+ The caller assembles the provider-specific request body via its own
+ build_*_request helpers (the prompts differ per script). This function
+ handles transport, error reporting, and token-usage extraction.
+ """
+ result, usage = _send_http_raw(
+ provider, api_key, model, request_data, timeout=timeout,
+ verbose=verbose
+ )
return _extract_text(provider, result), usage
+
+
+def send_request_raw(
+ provider: str,
+ api_key: str,
+ model: str,
+ request_data: dict[str, Any],
+ *,
+ timeout: int = 120,
+ verbose: bool = False,
+) -> tuple[dict[str, Any], TokenUsage]:
+ """Send a prebuilt request and return the raw response dict plus usage.
+
+ Used by tool-calling loops that need to inspect stop_reason / finish_reason
+ before extracting text.
+ """
+ return _send_http_raw(
+ provider, api_key, model, request_data, timeout=timeout,
+ verbose=verbose
+ )
diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 52601ac156..18ed445afe 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -29,6 +29,7 @@
list_providers,
print_token_summary,
send_request,
+ send_request_raw,
)
# Output formats
@@ -114,6 +115,152 @@
--- PATCH CONTENT ---
"""
+TOOL_PROMPT_EXTENSION = """\
+Use tools to gather context that improves the review. Specifically:
+
+- New files or scripts: use grep to find similar existing files and compare \
+structure, naming conventions, and patterns (e.g. for a new CI script, check \
+other scripts under .ci/).
+- Modified or called functions: use grep to find their declaration and \
+file_read to inspect the header or implementation.
+- New symbols, macros, or config keys: use grep to check whether similar names \
+already exist and whether naming conventions are consistent.
+- MAINTAINERS or documentation changes: use file_read to verify the surrounding \
+context is consistent.
+
+Each tool call costs tokens, so skip lookups that clearly add no value. But \
+when in doubt about an existing pattern or convention, look it up."""
+
+TOOLS_ANTHROPIC: list[dict] = [
+ {
+ "name": "grep",
+ "description": (
+ "Search the DPDK source tree for a pattern. Returns matching lines "
+ "with file paths and line numbers. Use to find API definitions, "
+ "usage examples, or code referenced by the patch."
+ ),
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "pattern": {
+ "type": "string",
+ "description": "Regular expression or literal string to "
+ "search for",
+ },
+ "path": {
+ "type": "string",
+ "description": (
+ "Directory or file path to search, relative to the "
+ "repo root. Defaults to '.' (entire tree)."
+ ),
+ },
+ "case_insensitive": {
+ "type": "boolean",
+ "description": "Ignore case when matching (default: false)",
+ },
+ },
+ "required": ["pattern"],
+ },
+ },
+ {
+ "name": "file_read",
+ "description": (
+ "Read lines from a file in the DPDK source tree. "
+ "Use to inspect headers, existing implementations, or files "
+ "referenced in the patch."
+ ),
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "File path relative to the repository root",
+ },
+ "offset": {
+ "type": "integer",
+ "description": "First line to return, 1-indexed "
+ "(default: 1)",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum lines to return (default: 100, "
+ "max: 500)",
+ },
+ },
+ "required": ["path"],
+ },
+ },
+]
+
+TOOLS_OPENAI: list[dict] = [
+ {
+ "type": "function",
+ "function": {
+ "name": "grep",
+ "description": (
+ "Search the DPDK source tree for a pattern. Returns matching "
+ "lines with file paths and line numbers. Use to find API "
+ "definitions, usage examples, or code referenced by the patch."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "pattern": {
+ "type": "string",
+ "description": "Regular expression or literal string "
+ "to search for",
+ },
+ "path": {
+ "type": "string",
+ "description": (
+ "Directory or file path to search, relative to the "
+ "repo root. Defaults to '.' (entire tree)."
+ ),
+ },
+ "case_insensitive": {
+ "type": "boolean",
+ "description": "Ignore case when matching (default: "
+ "false)",
+ },
+ },
+ "required": ["pattern"],
+ },
+ },
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "file_read",
+ "description": (
+ "Read lines from a file in the DPDK source tree. "
+ "Use to inspect headers, existing implementations, or files "
+ "referenced in the patch."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "File path relative to the repository "
+ "root",
+ },
+ "offset": {
+ "type": "integer",
+ "description": "First line to return, 1-indexed "
+ "(default: 1)",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum lines to return (default: 100, "
+ "max: 500)",
+ },
+ },
+ "required": ["path"],
+ },
+ },
+ },
+]
+
# Exit codes for review results
EXIT_CLEAN = 0
EXIT_WARNINGS = 2
@@ -158,9 +305,8 @@ def classify_review(review_text: str, output_format: str) -> int:
r"^<h[1-3]>\s*error", stripped
):
has_errors = True
- elif re.match(r"^(#{1,3}\s+)?(\*{0,2})warning", stripped) or re.match(
- r"^<h[1-3]>\s*warning", stripped
- ):
+ elif re.match(r"^(#{1,3}\s+)?(\*{0,2})warning", stripped) or \
+ re.match(r"^<h[1-3]>\s*warning", stripped):
has_warnings = True
if has_errors:
@@ -551,6 +697,340 @@ def build_google_request(
}
+def get_repo_root() -> str:
+ """Return the git repository root, falling back to cwd."""
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return result.stdout.strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return os.getcwd()
+
+
+def _tool_grep(tool_input: dict[str, Any], repo_root: str) -> str:
+ """Execute a grep tool call against the repository."""
+ pattern = tool_input.get("pattern", "")
+ rel_path = tool_input.get("path", ".")
+ case_insensitive = tool_input.get("case_insensitive", False)
+
+ repo_resolved = Path(repo_root).resolve()
+ search_path = (repo_resolved / rel_path).resolve()
+ if not str(search_path).startswith(str(repo_resolved)):
+ return "Error: path is outside the repository"
+ if not search_path.exists():
+ return f"Error: path not found: {rel_path}"
+
+ cmd = ["grep", "-nH"]
+ if case_insensitive:
+ cmd.append("-i")
+ if search_path.is_dir():
+ cmd.extend(
+ [
+ "-r",
+ "--include=*.[ch]",
+ "--include=*.py",
+ "--include=*.rst",
+ "--include=*.ini",
+ ]
+ )
+ cmd.extend(["--", pattern, str(search_path)])
+
+ try:
+ proc = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=30, errors="replace"
+ )
+ output = proc.stdout
+ if not output:
+ return "No matches found."
+ # Make paths relative to repo root for readability
+ prefix = str(repo_resolved) + "/"
+ output = output.replace(prefix, "")
+ lines = output.splitlines()
+ if len(lines) > 100:
+ truncated = "\n".join(lines[:100])
+ return f"{truncated}\n... ({len(lines) - 100} more lines truncated)"
+ return output.rstrip()
+ except subprocess.TimeoutExpired:
+ return "Error: grep timed out after 30 seconds"
+ except Exception as e:
+ return f"Error: grep failed: {e}"
+
+
+def _tool_file_read(tool_input: dict[str, Any], repo_root: str) -> str:
+ """Execute a file_read tool call against the repository."""
+ rel_path = tool_input.get("path", "")
+ offset = max(1, int(tool_input.get("offset", 1)))
+ limit = min(500, max(1, int(tool_input.get("limit", 100))))
+
+ repo_resolved = Path(repo_root).resolve()
+ file_path = (repo_resolved / rel_path).resolve()
+ if not str(file_path).startswith(str(repo_resolved)):
+ return "Error: path is outside the repository"
+ if not file_path.exists():
+ return f"Error: file not found: {rel_path}"
+ if not file_path.is_file():
+ return f"Error: not a file: {rel_path}"
+
+ try:
+ content = file_path.read_text(encoding="utf-8", errors="replace")
+ lines = content.splitlines()
+ total = len(lines)
+ start = offset - 1 # convert to 0-indexed
+ end = start + limit
+ selected = lines[start:end]
+ numbered = "\n".join(f"{offset + i}: {line}" for i, line in enumerate(selected))
+ if end < total:
+ numbered += f"\n... ({total - end} more lines; use offset={end + 1} to continue)"
+ return numbered
+ except Exception as e:
+ return f"Error reading file: {e}"
+
+
+def execute_tool(name: str, tool_input: dict[str, Any], repo_root: str) -> str:
+ """Dispatch a tool call by name and return the result string."""
+ if name == "grep":
+ return _tool_grep(tool_input, repo_root)
+ if name == "file_read":
+ return _tool_file_read(tool_input, repo_root)
+ return f"Error: unknown tool '{name}'"
+
+
+def call_api_with_tools_anthropic(
+ api_key: str,
+ model: str,
+ max_tokens: int,
+ system_prompt: str,
+ agents_content: str,
+ patch_content: str,
+ patch_name: str,
+ output_format: str,
+ verbose: bool,
+ timeout: int,
+ max_tool_rounds: int,
+ repo_root: str,
+) -> tuple[str, TokenUsage]:
+ """Anthropic API call with an iterative tool-use loop."""
+ format_instruction = FORMAT_INSTRUCTIONS.get(output_format, "")
+ user_prompt = USER_PROMPT.format(
+ patch_name=patch_name,
+ format_instruction=format_instruction + "\n\n" + TOOL_PROMPT_EXTENSION,
+ )
+
+ system: list[dict[str, Any]] = [
+ {"type": "text", "text": system_prompt},
+ {
+ "type": "text",
+ "text": agents_content,
+ "cache_control": {"type": "ephemeral"},
+ },
+ ]
+ messages: list[dict[str, Any]] = [
+ {"role": "user", "content": user_prompt + patch_content}
+ ]
+ total_usage = TokenUsage()
+
+ for _ in range(max_tool_rounds):
+ request_data: dict[str, Any] = {
+ "model": model,
+ "max_tokens": max_tokens,
+ "system": system,
+ "messages": messages,
+ "tools": TOOLS_ANTHROPIC,
+ }
+ api_result, usage = send_request_raw(
+ "anthropic", api_key, model, request_data, timeout=timeout, verbose=verbose
+ )
+ total_usage.add(usage)
+
+ stop_reason = api_result.get("stop_reason", "end_turn")
+ content_blocks = api_result.get("content", [])
+
+ if stop_reason != "tool_use":
+ text = "".join(
+ b.get("text", "") for b in content_blocks if b.get("type") == "text"
+ )
+ return text, total_usage
+
+ tool_use_blocks = [b for b in content_blocks if b.get("type") == "tool_use"]
+ if verbose:
+ for b in tool_use_blocks:
+ args_str = json.dumps(b.get("input", {}), separators=(",", ":"))
+ print(f"Tool call: {b['name']}({args_str})", file=sys.stderr)
+
+ messages.append({"role": "assistant", "content": content_blocks})
+ tool_results = [
+ {
+ "type": "tool_result",
+ "tool_use_id": b["id"],
+ "content": execute_tool(b["name"], b.get("input", {}), repo_root),
+ }
+ for b in tool_use_blocks
+ ]
+ messages.append({"role": "user", "content": tool_results})
+
+ # Exhausted rounds — append a text instruction to the last user message so
+ # the model understands it must switch from tool-calling to text-generation,
+ # then send with tool_choice:none to prevent further tool use.
+ if verbose:
+ print(
+ f"Tool round limit ({max_tool_rounds}) reached, forcing final judgment",
+ file=sys.stderr,
+ )
+ judgment_text = (
+ "You have reached the maximum number of tool call rounds. "
+ "Do not call any more tools. Based on all information gathered, "
+ "provide your complete final review now."
+ )
+ if messages and messages[-1].get("role") == "user":
+ last_content = messages[-1].get("content", [])
+ if isinstance(last_content, list):
+ messages[-1] = {
+ "role": "user",
+ "content": last_content + [{"type": "text", "text": judgment_text}],
+ }
+ request_data = {
+ "model": model,
+ "max_tokens": max_tokens,
+ "system": system,
+ "messages": messages,
+ "tools": TOOLS_ANTHROPIC,
+ "tool_choice": {"type": "none"},
+ }
+ api_result, usage = send_request_raw(
+ "anthropic", api_key, model, request_data, timeout=timeout, verbose=verbose
+ )
+ total_usage.add(usage)
+ content_blocks = api_result.get("content", [])
+ text = "".join(
+ b.get("text", "") for b in content_blocks if b.get("type") == "text"
+ )
+ if not text:
+ text = "(Review incomplete: tool call limit reached without a final response.)"
+ return text, total_usage
+
+
+def call_api_with_tools_openai(
+ api_key: str,
+ model: str,
+ max_tokens: int,
+ system_prompt: str,
+ agents_content: str,
+ patch_content: str,
+ patch_name: str,
+ output_format: str,
+ verbose: bool,
+ timeout: int,
+ max_tool_rounds: int,
+ repo_root: str,
+) -> tuple[str, TokenUsage]:
+ """OpenAI API call with an iterative tool-use loop."""
+ format_instruction = FORMAT_INSTRUCTIONS.get(output_format, "")
+ user_prompt = USER_PROMPT.format(
+ patch_name=patch_name,
+ format_instruction=format_instruction + "\n\n" + TOOL_PROMPT_EXTENSION,
+ )
+
+ messages: list[dict[str, Any]] = [
+ {"role": "system", "content": system_prompt},
+ {"role": "system", "content": agents_content},
+ {"role": "user", "content": user_prompt + patch_content},
+ ]
+ total_usage = TokenUsage()
+
+ for _ in range(max_tool_rounds):
+ request_data: dict[str, Any] = {
+ "model": model,
+ "max_tokens": max_tokens,
+ "messages": messages,
+ "tools": TOOLS_OPENAI,
+ }
+ api_result, usage = send_request_raw(
+ "openai", api_key, model, request_data, timeout=timeout, verbose=verbose
+ )
+ total_usage.add(usage)
+
+ choices = api_result.get("choices", [])
+ if not choices:
+ return "", total_usage
+
+ choice = choices[0]
+ finish_reason = choice.get("finish_reason", "stop")
+ message = choice.get("message", {})
+
+ if finish_reason != "tool_calls":
+ return message.get("content") or "", total_usage
+
+ tool_calls = message.get("tool_calls", [])
+ if verbose:
+ for tc in tool_calls:
+ fn = tc.get("function", {})
+ print(f"Tool call: {fn.get('name')}({fn.get('arguments', '')})", file=sys.stderr)
+
+ messages.append(
+ {
+ "role": "assistant",
+ "content": message.get("content"),
+ "tool_calls": tool_calls,
+ }
+ )
+
+ for tc in tool_calls:
+ fn = tc.get("function", {})
+ tool_name = fn.get("name", "")
+ try:
+ tool_input = json.loads(fn.get("arguments", "{}"))
+ except json.JSONDecodeError:
+ tool_input = {}
+ result_text = execute_tool(tool_name, tool_input, repo_root)
+ messages.append(
+ {
+ "role": "tool",
+ "tool_call_id": tc["id"],
+ "content": result_text,
+ }
+ )
+
+ # Exhausted rounds — add a user message directing the model to stop calling
+ # tools and deliver its final review, then send with tool_choice:none.
+ if verbose:
+ print(
+ f"Tool round limit ({max_tool_rounds}) reached, forcing final judgment",
+ file=sys.stderr,
+ )
+ messages.append(
+ {
+ "role": "user",
+ "content": (
+ "You have reached the maximum number of tool call rounds. "
+ "Do not call any more tools. Based on all information gathered, "
+ "provide your complete final review now."
+ ),
+ }
+ )
+ request_data = {
+ "model": model,
+ "max_tokens": max_tokens,
+ "messages": messages,
+ "tools": TOOLS_OPENAI,
+ "tool_choice": "none",
+ }
+ api_result, usage = send_request_raw(
+ "openai", api_key, model, request_data, timeout=timeout, verbose=verbose
+ )
+ total_usage.add(usage)
+ choices = api_result.get("choices", [])
+ if not choices:
+ return "(Review incomplete: tool call limit reached without a final response.)", total_usage
+ text = choices[0].get("message", {}).get("content") or ""
+ if not text:
+ text = "(Review incomplete: tool call limit reached without a final response.)"
+ return text, total_usage
+
+
def call_api(
provider: str,
api_key: str,
@@ -563,8 +1043,45 @@ def call_api(
output_format: str = "text",
verbose: bool = False,
timeout: int = 300,
+ max_tool_rounds: int = 0,
+ repo_root: str = "",
) -> tuple[str, TokenUsage]:
- """Build the per-provider request body and dispatch via _common."""
+ """Build the per-provider request body and dispatch via _common.
+
+ When max_tool_rounds > 0 and the provider is anthropic or openai, runs an
+ iterative tool-use loop before returning the final review text.
+ """
+ if max_tool_rounds > 0 and provider == "anthropic":
+ return call_api_with_tools_anthropic(
+ api_key,
+ model,
+ max_tokens,
+ system_prompt,
+ agents_content,
+ patch_content,
+ patch_name,
+ output_format,
+ verbose,
+ timeout,
+ max_tool_rounds,
+ repo_root,
+ )
+ if max_tool_rounds > 0 and provider == "openai":
+ return call_api_with_tools_openai(
+ api_key,
+ model,
+ max_tokens,
+ system_prompt,
+ agents_content,
+ patch_content,
+ patch_name,
+ output_format,
+ verbose,
+ timeout,
+ max_tool_rounds,
+ repo_root,
+ )
+
if provider == "anthropic":
request_data = build_anthropic_request(
model,
@@ -768,6 +1285,11 @@ def main() -> None:
stricter review rules: bug fixes only, no new features or APIs.
Any DPDK release with minor version .11 is an LTS release.
+Tool Calling (Anthropic and OpenAI only):
+ By default, the reviewer can call grep and file_read tools to look up
+ additional context from the source tree (up to 10 rounds). Use
+ --tool-rounds to change the limit or pass 0 to disable tool use.
+
Token Usage:
Use --show-tokens (or -v/--verbose) to print a token usage summary
on stderr after the run. Off by default.
@@ -840,6 +1362,14 @@ def main() -> None:
metavar="SECONDS",
help="API request timeout in seconds (default: 300)",
)
+ parser.add_argument(
+ "--tool-rounds",
+ type=int,
+ default=10,
+ metavar="N",
+ help="Max tool call rounds for Anthropic/OpenAI providers "
+ "(default: 10, 0 to disable tool calling)",
+ )
# Date and release options
parser.add_argument(
@@ -971,6 +1501,9 @@ def main() -> None:
patch_content = patch_path.read_text(encoding="utf-8", errors="replace")
patch_name = patch_path.name
+ # Repo root is used by tool calls (grep, file_read) to locate source files
+ repo_root = get_repo_root()
+
# Determine max tokens for this provider
max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
args.provider, 100000
@@ -1051,6 +1584,8 @@ def main() -> None:
args.output_format,
args.verbose,
args.timeout,
+ args.tool_rounds,
+ repo_root,
)
total_usage.add(call_usage)
all_reviews.append((patch_label, review_text))
@@ -1121,6 +1656,8 @@ def main() -> None:
args.output_format,
args.verbose,
args.timeout,
+ args.tool_rounds,
+ repo_root,
)
total_usage.add(call_usage)
all_reviews.append((chunk_label, review_text))
@@ -1150,6 +1687,11 @@ def main() -> None:
print(f"Large file mode: {args.large_file}", file=sys.stderr)
if args.split_patches:
print("Split patches: yes", file=sys.stderr)
+ if args.provider in ("anthropic", "openai"):
+ if args.tool_rounds > 0:
+ print(f"Tool calling: enabled (max {args.tool_rounds} rounds)", file=sys.stderr)
+ else:
+ print("Tool calling: disabled", file=sys.stderr)
if args.output:
print(f"Output file: {args.output}", file=sys.stderr)
if args.send_email:
@@ -1174,6 +1716,8 @@ def main() -> None:
args.output_format,
args.verbose,
args.timeout,
+ args.tool_rounds,
+ repo_root,
)
total_usage.add(call_usage)
--
2.51.0
^ permalink raw reply related [flat|nested] 6+ messages in thread
* Re: [RFC] devtools: add tool calling support to review-patch.py
2026-06-09 18:26 [RFC] devtools: add tool calling support to review-patch.py Aaron Conole
@ 2026-06-15 11:34 ` David Marchand
2026-09-09 14:10 ` Aaron Conole
2026-06-16 22:42 ` Stephen Hemminger
2026-09-09 14:13 ` [PATCH] devtools: add read-only tool calling support to AI review scripts Aaron Conole
2 siblings, 1 reply; 6+ messages in thread
From: David Marchand @ 2026-06-15 11:34 UTC (permalink / raw)
To: Aaron Conole; +Cc: dev, Stephen Hemminger
On Tue, 9 Jun 2026 at 20:26, Aaron Conole <aconole@redhat.com> wrote:
>
> Add an iterative tool-use loop to review-patch.py for the Anthropic
> and OpenAI providers. The reviewer can now look up additional context
> from the DPDK source tree when the patch alone is insufficient,
> rather than having to guess at surrounding code, API contracts, or
> function signatures.
>
> Tool calling is enabled by default with a limit of 10 rounds. Pass
> '--tool-rounds 0' to disable it and restore the previous single-shot
> behavior. The round limit prevents runaway cost on large patches
> that when reached will force the model to deliver a final judgement.
>
> Initial tool set:
> - grep Searches for regex across the file system with
> optional path restrictions and case-insensitive
> matches.
> - file_read Line range read of a specific path.
>
> Both tools are limited to the repository root to prevent path
> traversal. Path outputs are relative to the repo root.
>
> The system prompt is extended when tool calling is active to
> encourage the model to use tools only when genuinely needed,
> keeping unnecessary round trips and token costs under control
> and to a minimum.
>
> Internally, _common.py gains send_request_raw() (returning the
> raw response dict) so the tool-calling loops can inspect
> stop_reason / finish_reason before extracting text.
>
> Signed-off-by: Aaron Conole <aconole@redhat.com>
- I got a strange comment that a (valid) sha1 (from a Fixes: tag) was
unknown to the grep tool.
- Are those tools returning results on the current working directory?
If so, the result may differ depending on whether you applied the series or not.
Did you consider wrapping around "git grep $pattern origin/main" /
"git show origin/main:$file" ?
(determining the correct git reference may be hard..)
--
David Marchand
^ permalink raw reply [flat|nested] 6+ messages in thread
* Re: [RFC] devtools: add tool calling support to review-patch.py
2026-06-09 18:26 [RFC] devtools: add tool calling support to review-patch.py Aaron Conole
2026-06-15 11:34 ` David Marchand
@ 2026-06-16 22:42 ` Stephen Hemminger
2026-09-09 14:12 ` Aaron Conole
2026-09-09 14:13 ` [PATCH] devtools: add read-only tool calling support to AI review scripts Aaron Conole
2 siblings, 1 reply; 6+ messages in thread
From: Stephen Hemminger @ 2026-06-16 22:42 UTC (permalink / raw)
To: Aaron Conole; +Cc: dev, David Marchand
On Tue, 9 Jun 2026 14:26:47 -0400
Aaron Conole <aconole@redhat.com> wrote:
> Add an iterative tool-use loop to review-patch.py for the Anthropic
> and OpenAI providers. The reviewer can now look up additional context
> from the DPDK source tree when the patch alone is insufficient,
> rather than having to guess at surrounding code, API contracts, or
> function signatures.
>
> Tool calling is enabled by default with a limit of 10 rounds. Pass
> '--tool-rounds 0' to disable it and restore the previous single-shot
> behavior. The round limit prevents runaway cost on large patches
> that when reached will force the model to deliver a final judgement.
>
> Initial tool set:
> - grep Searches for regex across the file system with
> optional path restrictions and case-insensitive
> matches.
> - file_read Line range read of a specific path.
>
> Both tools are limited to the repository root to prevent path
> traversal. Path outputs are relative to the repo root.
>
> The system prompt is extended when tool calling is active to
> encourage the model to use tools only when genuinely needed,
> keeping unnecessary round trips and token costs under control
> and to a minimum.
>
> Internally, _common.py gains send_request_raw() (returning the
> raw response dict) so the tool-calling loops can inspect
> stop_reason / finish_reason before extracting text.
>
> Signed-off-by: Aaron Conole <aconole@redhat.com>
> ---
Well AI saw the security bypass here...
Error
=====
devtools/ai/review-patch.py: path containment check is bypassable
(_tool_grep and _tool_file_read)
repo_resolved = Path(repo_root).resolve()
search_path = (repo_resolved / rel_path).resolve()
if not str(search_path).startswith(str(repo_resolved)):
return "Error: path is outside the repository"
str.startswith() on the resolved path string is a prefix match, not a
path-component match. If repo_root resolves to /home/me/dpdk, then
/home/me/dpdk-private/secrets passes the check, since the string starts
with "/home/me/dpdk". The commit message states both tools are "limited
to the repository root to prevent path traversal" -- that property does
not hold for sibling directories sharing the name prefix. Same bug in
both tool helpers.
# is_relative_to (3.9+), raises nothing, no string games
if not search_path.is_relative_to(repo_resolved):
return "Error: path is outside the repository"
(Absolute rel_path and ../ escapes are already caught by resolve() +
this fix; only the prefix case is currently open.)
Warning
=======
devtools/ai/_common.py: unrelated reformatting mixed into a feature
patch. The docstring re-wrapping in print_token_summary(),
_extract_text(), _print_verbose_usage(), and the classify_review()
regex reflow in review-patch.py are cosmetic churn unrelated to tool
calling. Drop them or split into a separate cleanup; they inflate the
diff and obscure the actual change.
devtools/ai/review-patch.py: grep --include list excludes the files the
tool prompt points the model at. _tool_grep restricts to
*.[ch]/*.py/*.rst/*.ini, but TOOL_PROMPT_EXTENSION explicitly directs
the model to check ".ci/" scripts, meson.build, and MAINTAINERS. Those
are extensionless or shell, so grep silently returns "No matches found"
rather than surfacing the omission. Either widen the include set or
note the restriction in the tool description so the model doesn't draw
false conclusions from empty results.
Info
====
xai is OpenAI wire-compatible and already shares build_openai_request()
elsewhere, but tool calling is gated to provider == "openai" in
call_api() and the verbose banner. xai (and any future
OpenAI-compatible provider) falls back to single-shot with no notice.
Reusing call_api_with_tools_openai() for xai looks free.
send_request_raw() bypasses _extract_text(), which is where the
"error" in result check lived. The tool loops read stop_reason /
choices directly, so a provider error returned with HTTP 200 yields an
empty string and a misleading "clean" review instead of a hard failure.
Worth a guard on api_result.get("error") at the top of each loop body.
Default-on at 10 rounds is a behavior and cost change for every existing
caller, and grants repo-wide read to the model by default. Reasonable
for an RFC to propose, but worth calling out explicitly for the list --
some CI users may want opt-in rather than opt-out.
^ permalink raw reply [flat|nested] 6+ messages in thread
* Re: [RFC] devtools: add tool calling support to review-patch.py
2026-06-15 11:34 ` David Marchand
@ 2026-09-09 14:10 ` Aaron Conole
0 siblings, 0 replies; 6+ messages in thread
From: Aaron Conole @ 2026-09-09 14:10 UTC (permalink / raw)
To: David Marchand; +Cc: dev, Stephen Hemminger
David Marchand <david.marchand@redhat.com> writes:
> On Tue, 9 Jun 2026 at 20:26, Aaron Conole <aconole@redhat.com> wrote:
>>
>> Add an iterative tool-use loop to review-patch.py for the Anthropic
>> and OpenAI providers. The reviewer can now look up additional context
>> from the DPDK source tree when the patch alone is insufficient,
>> rather than having to guess at surrounding code, API contracts, or
>> function signatures.
>>
>> Tool calling is enabled by default with a limit of 10 rounds. Pass
>> '--tool-rounds 0' to disable it and restore the previous single-shot
>> behavior. The round limit prevents runaway cost on large patches
>> that when reached will force the model to deliver a final judgement.
>>
>> Initial tool set:
>> - grep Searches for regex across the file system with
>> optional path restrictions and case-insensitive
>> matches.
>> - file_read Line range read of a specific path.
>>
>> Both tools are limited to the repository root to prevent path
>> traversal. Path outputs are relative to the repo root.
>>
>> The system prompt is extended when tool calling is active to
>> encourage the model to use tools only when genuinely needed,
>> keeping unnecessary round trips and token costs under control
>> and to a minimum.
>>
>> Internally, _common.py gains send_request_raw() (returning the
>> raw response dict) so the tool-calling loops can inspect
>> stop_reason / finish_reason before extracting text.
>>
>> Signed-off-by: Aaron Conole <aconole@redhat.com>
>
> - I got a strange comment that a (valid) sha1 (from a Fixes: tag) was
> unknown to the grep tool.
>
>
> - Are those tools returning results on the current working directory?
> If so, the result may differ depending on whether you applied the series or not.
>
> Did you consider wrapping around "git grep $pattern origin/main" /
> "git show origin/main:$file" ?
> (determining the correct git reference may be hard..)
I'll post a new revision. I've been testing with expended tools. Some
of the errors are a result of not having the git log / git show / awk /
sed commands available (model tool usage is *very* sensitive). I've
added some safe versions and did quite a bit more testing. The result
is quite a bit more promising.
^ permalink raw reply [flat|nested] 6+ messages in thread
* Re: [RFC] devtools: add tool calling support to review-patch.py
2026-06-16 22:42 ` Stephen Hemminger
@ 2026-09-09 14:12 ` Aaron Conole
0 siblings, 0 replies; 6+ messages in thread
From: Aaron Conole @ 2026-09-09 14:12 UTC (permalink / raw)
To: Stephen Hemminger; +Cc: dev, David Marchand
Stephen Hemminger <stephen@networkplumber.org> writes:
> On Tue, 9 Jun 2026 14:26:47 -0400
> Aaron Conole <aconole@redhat.com> wrote:
>
>> Add an iterative tool-use loop to review-patch.py for the Anthropic
>> and OpenAI providers. The reviewer can now look up additional context
>> from the DPDK source tree when the patch alone is insufficient,
>> rather than having to guess at surrounding code, API contracts, or
>> function signatures.
>>
>> Tool calling is enabled by default with a limit of 10 rounds. Pass
>> '--tool-rounds 0' to disable it and restore the previous single-shot
>> behavior. The round limit prevents runaway cost on large patches
>> that when reached will force the model to deliver a final judgement.
>>
>> Initial tool set:
>> - grep Searches for regex across the file system with
>> optional path restrictions and case-insensitive
>> matches.
>> - file_read Line range read of a specific path.
>>
>> Both tools are limited to the repository root to prevent path
>> traversal. Path outputs are relative to the repo root.
>>
>> The system prompt is extended when tool calling is active to
>> encourage the model to use tools only when genuinely needed,
>> keeping unnecessary round trips and token costs under control
>> and to a minimum.
>>
>> Internally, _common.py gains send_request_raw() (returning the
>> raw response dict) so the tool-calling loops can inspect
>> stop_reason / finish_reason before extracting text.
>>
>> Signed-off-by: Aaron Conole <aconole@redhat.com>
>> ---
>
> Well AI saw the security bypass here...
>
> Error
> =====
>
> devtools/ai/review-patch.py: path containment check is bypassable
> (_tool_grep and _tool_file_read)
>
> repo_resolved = Path(repo_root).resolve()
> search_path = (repo_resolved / rel_path).resolve()
> if not str(search_path).startswith(str(repo_resolved)):
> return "Error: path is outside the repository"
>
> str.startswith() on the resolved path string is a prefix match, not a
> path-component match. If repo_root resolves to /home/me/dpdk, then
> /home/me/dpdk-private/secrets passes the check, since the string starts
> with "/home/me/dpdk". The commit message states both tools are "limited
> to the repository root to prevent path traversal" -- that property does
> not hold for sibling directories sharing the name prefix. Same bug in
> both tool helpers.
>
> # is_relative_to (3.9+), raises nothing, no string games
> if not search_path.is_relative_to(repo_resolved):
> return "Error: path is outside the repository"
>
> (Absolute rel_path and ../ escapes are already caught by resolve() +
> this fix; only the prefix case is currently open.)
>
>
> Warning
> =======
>
> devtools/ai/_common.py: unrelated reformatting mixed into a feature
> patch. The docstring re-wrapping in print_token_summary(),
> _extract_text(), _print_verbose_usage(), and the classify_review()
> regex reflow in review-patch.py are cosmetic churn unrelated to tool
> calling. Drop them or split into a separate cleanup; they inflate the
> diff and obscure the actual change.
>
> devtools/ai/review-patch.py: grep --include list excludes the files the
> tool prompt points the model at. _tool_grep restricts to
> *.[ch]/*.py/*.rst/*.ini, but TOOL_PROMPT_EXTENSION explicitly directs
> the model to check ".ci/" scripts, meson.build, and MAINTAINERS. Those
> are extensionless or shell, so grep silently returns "No matches found"
> rather than surfacing the omission. Either widen the include set or
> note the restriction in the tool description so the model doesn't draw
> false conclusions from empty results.
>
>
> Info
> ====
>
> xai is OpenAI wire-compatible and already shares build_openai_request()
> elsewhere, but tool calling is gated to provider == "openai" in
> call_api() and the verbose banner. xai (and any future
> OpenAI-compatible provider) falls back to single-shot with no notice.
> Reusing call_api_with_tools_openai() for xai looks free.
>
> send_request_raw() bypasses _extract_text(), which is where the
> "error" in result check lived. The tool loops read stop_reason /
> choices directly, so a provider error returned with HTTP 200 yields an
> empty string and a misleading "clean" review instead of a hard failure.
> Worth a guard on api_result.get("error") at the top of each loop body.
>
> Default-on at 10 rounds is a behavior and cost change for every existing
> caller, and grants repo-wide read to the model by default. Reasonable
> for an RFC to propose, but worth calling out explicitly for the list --
> some CI users may want opt-in rather than opt-out.
I think I've addressed many of the above issues. Posting the 'PATCH'
version.
^ permalink raw reply [flat|nested] 6+ messages in thread
* [PATCH] devtools: add read-only tool calling support to AI review scripts
2026-06-09 18:26 [RFC] devtools: add tool calling support to review-patch.py Aaron Conole
2026-06-15 11:34 ` David Marchand
2026-06-16 22:42 ` Stephen Hemminger
@ 2026-09-09 14:13 ` Aaron Conole
2 siblings, 0 replies; 6+ messages in thread
From: Aaron Conole @ 2026-09-09 14:13 UTC (permalink / raw)
To: dev; +Cc: stephen
Add an iterative tool-use loop to review-patch.py and review-doc.py
for Anthropic, OpenAI, and xAI providers. The AI reviewer can now
look up additional context from the DPDK source tree when the patch
or document alone is insufficient, rather than having to guess at
surrounding code, API contracts, commit history, or function
signatures.
Tool calling is enabled by default with a configurable limit
(default: 10 rounds). Pass '--no-tools' to disable it and restore
the previous single-shot behavior, or '--max-tool-rounds N' to
adjust the limit. The round limit prevents runaway execution on
complex reviews; when reached, the model delivers a final response
with the context gathered so far.
Tool set (_tools.py):
- git_log View commit history with optional filters (author,
date range, path, message grep)
- git_show Display specific commits or file contents at a
given ref, with optional diffstat
- grep Search for regex patterns across files with
configurable recursion, case sensitivity, match
limits, and context lines
- awk Process text with AWK programs (read-only, blocks
system(), file writes, and command execution)
- sed Process text with sed programs (read-only, -n mode
enforced, blocks write/execute/read commands)
- file_read Read file contents up to a configurable size limit
(default 1MB) with encoding support
All tools validate paths using Path().is_relative_to() to prevent
directory traversal attacks. Tools are confined to the git repository
root and include 30-second timeouts to prevent hanging.
The _common.py send_request() function now handles the tool-calling
loop for supported providers, inspecting stop_reason (Anthropic) or
finish_reason (OpenAI/xAI) to determine if tool execution is needed,
then converting tool definitions between provider formats as required.
Assisted-by: Claude:sonnet-4.5
Signed-off-by: Aaron Conole <aconole@redhat.com>
---
RFC -> PATCH:
- More (read-only) tools
- Resolve (most?) security escapes
- xAI support (since it is openAI compatible)
devtools/ai/_common.py | 273 +++++++++++++--
devtools/ai/_tools.py | 674 ++++++++++++++++++++++++++++++++++++
devtools/ai/review-doc.py | 18 +
devtools/ai/review-patch.py | 22 ++
4 files changed, 966 insertions(+), 21 deletions(-)
create mode 100644 devtools/ai/_tools.py
diff --git a/devtools/ai/_common.py b/devtools/ai/_common.py
index 07a0411aaf..bdbc91034f 100644
--- a/devtools/ai/_common.py
+++ b/devtools/ai/_common.py
@@ -369,39 +369,35 @@ def _print_verbose_usage(usage: TokenUsage) -> None:
print("===================", file=sys.stderr)
-def send_request(
+def _execute_api_call(
provider: str,
auth: str,
model: str,
request_data: dict[str, Any],
- *,
- timeout: int = 120,
- verbose: bool = False,
-) -> tuple[str, TokenUsage]:
- """Send a prebuilt request to a provider and return (response_text, usage).
-
- The caller assembles the provider-specific request body via its own
- build_*_request helpers (the prompts differ per script). This function
- handles transport, error reporting, and token-usage extraction.
+ timeout: int,
+) -> dict[str, Any]:
+ """Execute a single API call and return the result.
Args:
- provider: Provider name (anthropic, openai, xai, google)
- auth: Authentication string - either "direct:<api_key>" or "vertex"
+ provider: Provider name
+ auth: Authentication string
model: Model identifier
- request_data: Provider-specific request payload
+ request_data: Request payload
timeout: Request timeout in seconds
- verbose: Show detailed token usage
Returns:
- Tuple of (response_text, token_usage)
+ API response as dictionary
+
+ Raises:
+ Calls error() on failure (does not return)
"""
- url, headers, request_data = _build_request_meta(provider, auth, model, request_data)
- body = json.dumps(request_data).encode("utf-8")
+ url, headers, req_data = _build_request_meta(provider, auth, model, request_data)
+ body = json.dumps(req_data).encode("utf-8")
req = Request(url, data=body, headers=headers)
try:
with urlopen(req, timeout=timeout) as response:
- result = json.loads(response.read().decode("utf-8"))
+ return json.loads(response.read().decode("utf-8"))
except HTTPError as e:
error_body = e.read().decode("utf-8")
try:
@@ -421,7 +417,242 @@ def send_request(
except TimeoutError:
error(f"Request timed out after {timeout} seconds")
- usage = _extract_usage(provider, result)
+
+def _handle_anthropic_tool_use(
+ result: dict[str, Any],
+ messages: list[dict[str, Any]],
+ verbose: bool,
+ round_num: int,
+) -> bool:
+ """Handle Anthropic tool use response.
+
+ Args:
+ result: API response
+ messages: Message history (modified in place)
+ verbose: Print debug info
+ round_num: Current round number (for logging)
+
+ Returns:
+ True if tools were used, False otherwise
+ """
+ stop_reason = result.get("stop_reason")
+ if stop_reason != "tool_use":
+ return False
+
+ try:
+ from _tools import execute_tool
+ except ImportError:
+ def execute_tool(tool_name, tool_input):
+ raise RuntimeError(f"Cannot run {tool_name} - bad _tools.py")
+
+ content_blocks = result.get("content", [])
+ tool_results = []
+
+ if verbose:
+ print(f"\n=== Tool Use Round {round_num + 1} ===", file=sys.stderr)
+
+ for block in content_blocks:
+ if block.get("type") == "tool_use":
+ tool_name = block.get("name")
+ tool_input = block.get("input", {})
+ tool_use_id = block.get("id")
+
+ if verbose:
+ print(f"Calling tool: {tool_name}", file=sys.stderr)
+ print(f"Input: {json.dumps(tool_input, indent=2)}", file=sys.stderr)
+
+ # Execute the tool
+ try:
+ tool_output = execute_tool(tool_name, tool_input)
+ is_error = False
+ except Exception as e:
+ tool_output = f"Tool error: {e}"
+ is_error = True
+
+ if verbose:
+ output_preview = tool_output[:200] + "..." if len(tool_output) > 200 else tool_output
+ print(f"Output: {output_preview}", file=sys.stderr)
+ if is_error:
+ print(f"Error occurred", file=sys.stderr)
+
+ tool_results.append({
+ "type": "tool_result",
+ "tool_use_id": tool_use_id,
+ "content": tool_output,
+ "is_error": is_error,
+ })
+
+ # Add assistant message with tool use
+ messages.append({
+ "role": "assistant",
+ "content": content_blocks,
+ })
+
+ # Add user message with tool results
+ messages.append({
+ "role": "user",
+ "content": tool_results,
+ })
+
+ return True
+
+
+def _handle_openai_tool_use(
+ result: dict[str, Any],
+ messages: list[dict[str, Any]],
+ verbose: bool,
+ round_num: int,
+) -> bool:
+ """Handle OpenAI/xAI tool use response.
+
+ Args:
+ result: API response
+ messages: Message history (modified in place)
+ verbose: Print debug info
+ round_num: Current round number (for logging)
+
+ Returns:
+ True if tools were used, False otherwise
+ """
+ choices = result.get("choices", [])
+ if not choices:
+ return False
+
+ message = choices[0].get("message", {})
+ tool_calls = message.get("tool_calls")
+
+ if not tool_calls:
+ return False
+
if verbose:
- _print_verbose_usage(usage)
- return _extract_text(provider, result), usage
+ print(f"\n=== Tool Use Round {round_num + 1} ===", file=sys.stderr)
+
+ # Add assistant message with tool calls
+ messages.append(message)
+
+ # Execute each tool and collect results
+ tool_messages = []
+ for tool_call in tool_calls:
+ tool_id = tool_call.get("id")
+ function = tool_call.get("function", {})
+ tool_name = function.get("name")
+ tool_args_str = function.get("arguments", "{}")
+
+ try:
+ tool_input = json.loads(tool_args_str)
+ except json.JSONDecodeError:
+ tool_input = {}
+
+ if verbose:
+ print(f"Calling tool: {tool_name}", file=sys.stderr)
+ print(f"Input: {json.dumps(tool_input, indent=2)}", file=sys.stderr)
+
+ # Execute the tool
+ try:
+ from _tools import execute_tool
+ tool_output = execute_tool(tool_name, tool_input)
+ except Exception as e:
+ tool_output = f"Tool error: {e}"
+
+ if verbose:
+ output_preview = tool_output[:200] + "..." if len(tool_output) > 200 else tool_output
+ print(f"Output: {output_preview}", file=sys.stderr)
+
+ tool_messages.append({
+ "role": "tool",
+ "tool_call_id": tool_id,
+ "name": tool_name,
+ "content": tool_output,
+ })
+
+ # Add all tool results as separate messages
+ messages.extend(tool_messages)
+ return True
+
+
+def send_request(
+ provider: str,
+ auth: str,
+ model: str,
+ request_data: dict[str, Any],
+ *,
+ timeout: int = 120,
+ verbose: bool = False,
+ enable_tools: bool = False,
+ max_tool_rounds: int = 10,
+) -> tuple[str, TokenUsage]:
+ """Send a prebuilt request to a provider and return (response_text, usage).
+
+ The caller assembles the provider-specific request body via its own
+ build_*_request helpers (the prompts differ per script). This function
+ handles transport, error reporting, and token-usage extraction.
+
+ If enable_tools is True, implements a tool-use loop where the model can
+ call tools, this function executes them, and sends results back until
+ the model returns a text response.
+
+ Args:
+ provider: Provider name (anthropic, openai, xai, google)
+ auth: Authentication string - either "direct:<api_key>" or "vertex"
+ model: Model identifier
+ request_data: Provider-specific request payload
+ timeout: Request timeout in seconds
+ verbose: Show detailed token usage
+ enable_tools: Enable tool calling support
+ max_tool_rounds: Maximum number of tool calling rounds (default: 10)
+
+ Returns:
+ Tuple of (response_text, token_usage)
+ """
+ # Add tools to request if enabled
+ if enable_tools:
+ if provider == "anthropic":
+ from _tools import get_tools_for_provider
+ request_data["tools"] = get_tools_for_provider(provider)
+ elif provider in ("openai", "xai"):
+ from _tools import get_tools_for_provider
+ request_data["tools"] = get_tools_for_provider(provider)
+ # Disable parallel tool calling for OpenAI/xAI (sequential execution only)
+ request_data["parallel_tool_calls"] = False
+ elif provider == "google":
+ # Google Gemini tool calling not yet implemented
+ if verbose:
+ print("Warning: Tool calling not yet supported for Google Gemini", file=sys.stderr)
+ enable_tools = False
+
+ total_usage = TokenUsage()
+ messages = request_data.get("messages", [])
+
+ # Tool use loop
+ for round_num in range(max_tool_rounds):
+ result = _execute_api_call(provider, auth, model, request_data, timeout)
+
+ usage = _extract_usage(provider, result)
+ total_usage.add(usage)
+
+ # Check if tools were used
+ tools_used = False
+ if enable_tools:
+ if provider == "anthropic":
+ tools_used = _handle_anthropic_tool_use(result, messages, verbose, round_num)
+ elif provider in ("openai", "xai"):
+ tools_used = _handle_openai_tool_use(result, messages, verbose, round_num)
+
+ if not tools_used:
+ # Final response
+ if verbose and round_num > 0:
+ print(f"=== Tool Use Complete ({round_num} rounds) ===\n", file=sys.stderr)
+ if verbose:
+ _print_verbose_usage(total_usage)
+ return _extract_text(provider, result), total_usage
+
+ # Update messages for next round
+ request_data["messages"] = messages
+
+ # Max rounds exceeded
+ if verbose:
+ print(f"Warning: Max tool rounds ({max_tool_rounds}) exceeded", file=sys.stderr)
+ _print_verbose_usage(total_usage)
+
+ # Return whatever we have
+ return _extract_text(provider, result), total_usage
diff --git a/devtools/ai/_tools.py b/devtools/ai/_tools.py
new file mode 100644
index 0000000000..a74d354982
--- /dev/null
+++ b/devtools/ai/_tools.py
@@ -0,0 +1,674 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Aaron Conole
+
+"""
+Read-only tool calling support for DPDK AI review scripts.
+
+Provides secure, sandboxed tools for AI to gather additional context:
+- git log: View commit history
+- git show: View specific commits
+- grep: Search file contents
+- awk/sed: Parse and extract text (read-only)
+- file_read: Read file contents
+
+All tools validate paths to prevent escaping the git repository.
+"""
+
+import json
+import re
+import subprocess
+from pathlib import Path
+from typing import Any, NoReturn
+
+
+class ToolError(Exception):
+ """Raised when a tool execution fails."""
+ pass
+
+
+class SecurityError(Exception):
+ """Raised when a security constraint is violated."""
+ pass
+
+
+def get_git_root() -> Path:
+ """Get the root directory of the git repository.
+
+ Returns:
+ Path to the git repository root
+
+ Raises:
+ ToolError: If not in a git repository
+ """
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return Path(result.stdout.strip()).resolve()
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
+ raise ToolError(f"Not in a git repository: {e}") from e
+
+
+def validate_path(path_str: str, git_root: Path) -> Path:
+ """Validate that a path is within the git repository.
+
+ Uses Path.is_relative_to() to prevent directory traversal attacks.
+
+ Args:
+ path_str: Path string to validate
+ git_root: Root of the git repository
+
+ Returns:
+ Resolved absolute Path object
+
+ Raises:
+ SecurityError: If path escapes the git repository
+ """
+ try:
+ # Convert to absolute path and resolve symlinks
+ if Path(path_str).is_absolute():
+ resolved = Path(path_str).resolve()
+ else:
+ resolved = (git_root / path_str).resolve()
+
+ # Check if path is within git root
+ if not resolved.is_relative_to(git_root):
+ raise SecurityError(
+ f"Path '{path_str}' escapes git repository root '{git_root}'"
+ )
+
+ return resolved
+
+ except (ValueError, OSError, RuntimeError) as e:
+ raise SecurityError(f"Invalid path '{path_str}': {e}") from e
+
+
+def validate_git_ref(ref: str) -> None:
+ """Validate a git reference (commit hash, branch, tag).
+
+ Args:
+ ref: Git reference to validate
+
+ Raises:
+ SecurityError: If reference contains suspicious characters
+ """
+ # Allow alphanumeric, -, _, /, ^, ~, @ (normal git ref characters)
+ # Disallow command injection characters: ; | & $ ( ) ` < > etc.
+ if not re.match(r'^[a-zA-Z0-9._/^~@-]+$', ref):
+ raise SecurityError(f"Invalid git reference: {ref}")
+
+
+def tool_git_log(args: dict[str, Any], git_root: Path) -> str:
+ """Execute git log with controlled arguments.
+
+ Args:
+ args: Dictionary with optional keys:
+ - max_count: Maximum number of commits (default: 20, max: 100)
+ - since: Date/time since (e.g., "2 weeks ago")
+ - until: Date/time until
+ - path: File path to filter by
+ - grep: Commit message grep pattern
+ - author: Author name/email filter
+ - oneline: Use oneline format (default: False)
+
+ Returns:
+ Git log output as string
+
+ Raises:
+ ToolError: If git command fails
+ SecurityError: If validation fails
+ """
+ cmd = ["git", "log"]
+
+ # Validate and add max_count
+ max_count = args.get("max_count", 20)
+ if not isinstance(max_count, int) or max_count < 1 or max_count > 100:
+ raise ToolError("max_count must be an integer between 1 and 100")
+ cmd.extend([f"-{max_count}"])
+
+ # Add optional filters
+ if args.get("oneline"):
+ cmd.append("--oneline")
+ else:
+ cmd.append("--format=%H%n%an <%ae>%n%ad%n%s%n%b%n---")
+
+ if "since" in args:
+ cmd.extend(["--since", args["since"]])
+
+ if "until" in args:
+ cmd.extend(["--until", args["until"]])
+
+ if "author" in args:
+ cmd.extend(["--author", args["author"]])
+
+ if "grep" in args:
+ cmd.extend(["--grep", args["grep"]])
+
+ # Validate and add path filter
+ if "path" in args:
+ path = validate_path(args["path"], git_root)
+ cmd.extend(["--", str(path)])
+
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=git_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ )
+ return result.stdout
+ except subprocess.TimeoutExpired:
+ raise ToolError("git log timed out after 30 seconds")
+ except subprocess.CalledProcessError as e:
+ raise ToolError(f"git log failed: {e.stderr}")
+
+
+def tool_git_show(args: dict[str, Any], git_root: Path) -> str:
+ """Show a git commit or object.
+
+ Args:
+ args: Dictionary with keys:
+ - ref: Git reference (commit hash, branch, tag)
+ - path: Optional path to show specific file at that ref
+ - stat: Show diffstat only (default: False)
+
+ Returns:
+ Git show output as string
+
+ Raises:
+ ToolError: If git command fails
+ SecurityError: If validation fails
+ """
+ if "ref" not in args:
+ raise ToolError("ref parameter is required")
+
+ ref = args["ref"]
+ validate_git_ref(ref)
+
+ cmd = ["git", "show"]
+
+ if args.get("stat"):
+ cmd.append("--stat")
+
+ # Construct the ref:path or just ref
+ if "path" in args:
+ path = validate_path(args["path"], git_root)
+ # Use relative path for git show
+ rel_path = path.relative_to(git_root)
+ cmd.append(f"{ref}:{rel_path}")
+ else:
+ cmd.append(ref)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=git_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ )
+ return result.stdout
+ except subprocess.TimeoutExpired:
+ raise ToolError("git show timed out after 30 seconds")
+ except subprocess.CalledProcessError as e:
+ raise ToolError(f"git show failed: {e.stderr}")
+
+
+def tool_grep(args: dict[str, Any], git_root: Path) -> str:
+ """Search for pattern in files using grep.
+
+ Args:
+ args: Dictionary with keys:
+ - pattern: Search pattern (required)
+ - path: File or directory path (default: current directory)
+ - recursive: Recursive search (default: True)
+ - ignore_case: Case-insensitive search (default: False)
+ - max_count: Maximum matches per file (default: 100)
+ - context: Lines of context (default: 0)
+
+ Returns:
+ Grep output as string
+
+ Raises:
+ ToolError: If grep fails
+ SecurityError: If validation fails
+ """
+ if "pattern" not in args:
+ raise ToolError("pattern parameter is required")
+
+ pattern = args["pattern"]
+ path = validate_path(args.get("path", "."), git_root)
+
+ cmd = ["grep", "--color=never"]
+
+ if args.get("ignore_case", False):
+ cmd.append("-i")
+
+ if args.get("recursive", True) and path.is_dir():
+ cmd.append("-r")
+
+ max_count = args.get("max_count", 100)
+ if not isinstance(max_count, int) or max_count < 0:
+ raise ToolError("max_count must be a non-negative integer")
+ if max_count > 0:
+ cmd.extend(["-m", str(max_count)])
+
+ context = args.get("context", 0)
+ if not isinstance(context, int) or context < 0 or context > 100:
+ raise ToolError(
+ "context must be a non-negative integer not greater than 100")
+ cmd.extend(["-C", str(context)])
+
+ # Use -- to prevent pattern from being interpreted as an option
+ cmd.extend(["--", pattern, str(path)])
+
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=git_root,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ # grep returns 1 if no matches, which is not an error
+ if result.returncode not in (0, 1):
+ raise ToolError(f"grep failed: {result.stderr}")
+ return result.stdout
+ except subprocess.TimeoutExpired:
+ raise ToolError("grep timed out after 30 seconds")
+
+
+def tool_awk(args: dict[str, Any], git_root: Path) -> str:
+ """Execute awk for text processing (read-only).
+
+ Args:
+ args: Dictionary with keys:
+ - program: AWK program (required)
+ - path: File path (required)
+
+ Returns:
+ AWK output as string
+
+ Raises:
+ ToolError: If awk fails
+ SecurityError: If validation fails or program attempts writes
+ """
+ if "program" not in args or "path" not in args:
+ raise ToolError("program and path parameters are required")
+
+ program = args["program"]
+ path = validate_path(args["path"], git_root)
+
+ # Security: Disallow dangerous awk features
+ # Block system(), print/printf redirects, pipes, and getline
+ dangerous_patterns = [
+ r'system\s*\(',
+ r'(print|printf)\s*(.*\s*)>\s*', # any print/printf redirect (with/without quotes)
+ r'(?<![|&])\|(?![|&])\s*', # pipe not preceded/followed by another | or &
+ r'getline', # all getline operations
+ ]
+ for pattern in dangerous_patterns:
+ if re.search(pattern, program):
+ raise SecurityError(f"AWK program contains forbidden pattern: {pattern}")
+
+ if not path.exists():
+ raise ToolError(f"File not found: {path}")
+
+ cmd = ["awk", program, str(path)]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=git_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ )
+ return result.stdout
+ except subprocess.TimeoutExpired:
+ raise ToolError("awk timed out after 30 seconds")
+ except subprocess.CalledProcessError as e:
+ raise ToolError(f"awk failed: {e.stderr}")
+
+
+def tool_sed(args: dict[str, Any], git_root: Path) -> str:
+ """Execute sed for text processing (read-only).
+
+ Args:
+ args: Dictionary with keys:
+ - program: sed program (required)
+ - path: File path (required)
+
+ Returns:
+ sed output as string
+
+ Raises:
+ ToolError: If sed fails
+ SecurityError: If validation fails or program attempts writes
+ """
+ if "program" not in args or "path" not in args:
+ raise ToolError("program and path parameters are required")
+
+ program = args["program"]
+ path = validate_path(args["path"], git_root)
+
+ # Security: Force read-only mode, disallow write/execute commands
+ # Block w (write), W (Write), e (execute), r/R (read from file), Q (quit with code)
+ dangerous_patterns = [
+ r'[wWeRQ]\s', # write, Write, execute, Read, Quit
+ r'\d+[wWeRQ]$', # write/execute/read/quit with address
+ r'[rR][/\s]', # read from file (with or without whitespace)
+ ]
+ for pattern in dangerous_patterns:
+ if re.search(pattern, program):
+ raise SecurityError(f"sed program contains forbidden pattern: {pattern}")
+
+ if not path.exists():
+ raise ToolError(f"File not found: {path}")
+
+ # Always use -n (suppress automatic printing) to prevent side effects
+ cmd = ["sed", "-n", program, str(path)]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=git_root,
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ )
+ return result.stdout
+ except subprocess.TimeoutExpired:
+ raise ToolError("sed timed out after 30 seconds")
+ except subprocess.CalledProcessError as e:
+ raise ToolError(f"sed failed: {e.stderr}")
+
+
+def tool_file_read(args: dict[str, Any], git_root: Path) -> str:
+ """Read contents of a file.
+
+ Args:
+ args: Dictionary with keys:
+ - path: File path (required)
+ - max_size: Maximum file size in bytes (default: 1MB)
+ - encoding: Text encoding (default: utf-8)
+
+ Returns:
+ File contents as string
+
+ Raises:
+ ToolError: If file read fails
+ SecurityError: If validation fails
+ """
+ if "path" not in args:
+ raise ToolError("path parameter is required")
+
+ path = validate_path(args["path"], git_root)
+ max_size = args.get("max_size", 1024 * 1024) # 1MB default
+ encoding = args.get("encoding", "utf-8")
+
+ if not path.exists():
+ raise ToolError(f"File not found: {path}")
+
+ if not path.is_file():
+ raise ToolError(f"Not a file: {path}")
+
+ # Check file size
+ file_size = path.stat().st_size
+ if file_size > max_size:
+ raise ToolError(
+ f"File too large: {file_size} bytes (max: {max_size})"
+ )
+
+ try:
+ # Use errors='replace' to handle non-UTF8 bytes gracefully
+ return path.read_text(encoding=encoding, errors='replace')
+ except Exception as e:
+ raise ToolError(f"Failed to read file: {e}") from e
+
+
+# Tool definitions for Anthropic API
+TOOL_DEFINITIONS = [
+ {
+ "name": "git_log",
+ "description": "View git commit history with optional filters. Use this to understand recent changes, find related commits, or trace the history of specific files.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "max_count": {
+ "type": "integer",
+ "description": "Maximum number of commits to show (1-100, default: 20)",
+ "minimum": 1,
+ "maximum": 100,
+ },
+ "since": {
+ "type": "string",
+ "description": "Show commits more recent than date (e.g., '2 weeks ago', '2024-01-01')",
+ },
+ "until": {
+ "type": "string",
+ "description": "Show commits older than date",
+ },
+ "path": {
+ "type": "string",
+ "description": "Only show commits affecting this file path",
+ },
+ "grep": {
+ "type": "string",
+ "description": "Only show commits with messages matching this pattern",
+ },
+ "author": {
+ "type": "string",
+ "description": "Only show commits by this author",
+ },
+ "oneline": {
+ "type": "boolean",
+ "description": "Use compact one-line format (default: false)",
+ },
+ },
+ },
+ },
+ {
+ "name": "git_show",
+ "description": "Show the contents of a git commit or a specific file at a given commit. Use this to examine what changed in a specific commit or to view historical file contents.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "ref": {
+ "type": "string",
+ "description": "Git reference (commit hash, branch name, tag, or HEAD~N)",
+ },
+ "path": {
+ "type": "string",
+ "description": "Optional: specific file path to show at this ref",
+ },
+ "stat": {
+ "type": "boolean",
+ "description": "Show only diffstat (default: false)",
+ },
+ },
+ "required": ["ref"],
+ },
+ },
+ {
+ "name": "grep",
+ "description": "Search for text patterns in files. Use this to find specific code patterns, function definitions, or configuration values.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "pattern": {
+ "type": "string",
+ "description": "Search pattern (supports regex)",
+ },
+ "path": {
+ "type": "string",
+ "description": "File or directory to search (default: current directory)",
+ },
+ "recursive": {
+ "type": "boolean",
+ "description": "Search recursively in directories (default: true)",
+ },
+ "ignore_case": {
+ "type": "boolean",
+ "description": "Case-insensitive search (default: false)",
+ },
+ "max_count": {
+ "type": "integer",
+ "description": "Maximum matches per file (default: 100)",
+ },
+ "context": {
+ "type": "integer",
+ "description": "Lines of context around matches (default: 0)",
+ },
+ },
+ "required": ["pattern"],
+ },
+ },
+ {
+ "name": "awk",
+ "description": "Process text files using AWK (read-only). Use this to extract columns, filter lines, or perform text transformations. Write/execute operations are blocked.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "program": {
+ "type": "string",
+ "description": "AWK program to execute",
+ },
+ "path": {
+ "type": "string",
+ "description": "File to process",
+ },
+ },
+ "required": ["program", "path"],
+ },
+ },
+ {
+ "name": "sed",
+ "description": "Process text files using sed (read-only). Use this to extract or transform text. Write/execute operations are blocked and -n flag is always enabled.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "program": {
+ "type": "string",
+ "description": "sed program to execute (use p command to print)",
+ },
+ "path": {
+ "type": "string",
+ "description": "File to process",
+ },
+ },
+ "required": ["program", "path"],
+ },
+ },
+ {
+ "name": "file_read",
+ "description": "Read the contents of a file. Use this to examine source code, documentation, or configuration files.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the file to read",
+ },
+ "max_size": {
+ "type": "integer",
+ "description": "Maximum file size in bytes (default: 1048576 = 1MB)",
+ },
+ "encoding": {
+ "type": "string",
+ "description": "Text encoding (default: utf-8)",
+ },
+ },
+ "required": ["path"],
+ },
+ },
+]
+
+
+# Map tool names to handler functions
+TOOL_HANDLERS = {
+ "git_log": tool_git_log,
+ "git_show": tool_git_show,
+ "grep": tool_grep,
+ "awk": tool_awk,
+ "sed": tool_sed,
+ "file_read": tool_file_read,
+}
+
+
+def execute_tool(tool_name: str, tool_args: dict[str, Any]) -> str:
+ """Execute a tool and return its output.
+
+ Args:
+ tool_name: Name of the tool to execute
+ tool_args: Arguments for the tool
+
+ Returns:
+ Tool output as string
+
+ Raises:
+ ToolError: If tool execution fails
+ SecurityError: If security validation fails
+ """
+ if tool_name not in TOOL_HANDLERS:
+ raise ToolError(f"Unknown tool: {tool_name}")
+
+ git_root = get_git_root()
+ handler = TOOL_HANDLERS[tool_name]
+
+ try:
+ return handler(tool_args, git_root)
+ except (ToolError, SecurityError):
+ raise
+ except Exception as e:
+ raise ToolError(f"Tool execution failed: {e}") from e
+
+
+def convert_tools_to_openai_format(anthropic_tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Convert Anthropic tool definitions to OpenAI function calling format.
+
+ Args:
+ anthropic_tools: List of tool definitions in Anthropic format
+
+ Returns:
+ List of tool definitions in OpenAI format
+ """
+ openai_tools = []
+ for tool in anthropic_tools:
+ openai_tool = {
+ "type": "function",
+ "function": {
+ "name": tool["name"],
+ "description": tool["description"],
+ "parameters": tool["input_schema"],
+ }
+ }
+ openai_tools.append(openai_tool)
+ return openai_tools
+
+
+def get_tools_for_provider(provider: str) -> list[dict[str, Any]]:
+ """Get tool definitions in the format required by the provider.
+
+ Args:
+ provider: Provider name (anthropic, openai, xai, google)
+
+ Returns:
+ List of tool definitions in provider-specific format
+ """
+ if provider == "anthropic":
+ return TOOL_DEFINITIONS
+ elif provider in ("openai", "xai"):
+ return convert_tools_to_openai_format(TOOL_DEFINITIONS)
+ else:
+ # Google Gemini uses a different format, not yet implemented
+ return []
diff --git a/devtools/ai/review-doc.py b/devtools/ai/review-doc.py
index e01be077fe..a084678ec5 100755
--- a/devtools/ai/review-doc.py
+++ b/devtools/ai/review-doc.py
@@ -360,6 +360,8 @@ def call_api(
include_diff_markers: bool = False,
verbose: bool = False,
timeout: int = 120,
+ enable_tools: bool = True,
+ max_tool_rounds: int = 10,
) -> tuple[str, TokenUsage]:
"""Build the per-provider request body and dispatch via _common."""
if provider == "anthropic":
@@ -399,6 +401,8 @@ def call_api(
request_data,
timeout=timeout,
verbose=verbose,
+ enable_tools=enable_tools,
+ max_tool_rounds=max_tool_rounds,
)
@@ -665,6 +669,18 @@ def main() -> None:
metavar="SECONDS",
help="API request timeout in seconds (default: 120)",
)
+ parser.add_argument(
+ "--no-tools",
+ action="store_true",
+ help="Disable tool calling (git, grep, file read). Tools are enabled by default.",
+ )
+ parser.add_argument(
+ "--max-tool-rounds",
+ type=int,
+ default=10,
+ metavar="N",
+ help="Maximum tool calling rounds (default: 10)",
+ )
# Email options
email_group = parser.add_argument_group("Email Options")
@@ -811,6 +827,8 @@ def main() -> None:
args.diff,
args.verbose,
args.timeout,
+ enable_tools=not args.no_tools,
+ max_tool_rounds=args.max_tool_rounds,
)
total_usage.add(call_usage)
diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 5f8d9ed772..a3481290be 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -589,6 +589,8 @@ def call_api(
output_format: str = "text",
verbose: bool = False,
timeout: int = 300,
+ enable_tools: bool = True,
+ max_tool_rounds: int = 10,
) -> tuple[str, TokenUsage]:
"""Build the per-provider request body and dispatch via _common."""
if provider == "anthropic":
@@ -625,6 +627,8 @@ def call_api(
request_data,
timeout=timeout,
verbose=verbose,
+ enable_tools=enable_tools,
+ max_tool_rounds=max_tool_rounds,
)
@@ -870,6 +874,18 @@ def main() -> None:
metavar="SECONDS",
help="API request timeout in seconds (default: 300)",
)
+ parser.add_argument(
+ "--no-tools",
+ action="store_true",
+ help="Disable tool calling (git, grep, file read). Tools are enabled by default.",
+ )
+ parser.add_argument(
+ "--max-tool-rounds",
+ type=int,
+ default=10,
+ metavar="N",
+ help="Maximum tool calling rounds (default: 10)",
+ )
# Date and release options
parser.add_argument(
@@ -1079,6 +1095,8 @@ def main() -> None:
args.output_format,
args.verbose,
args.timeout,
+ enable_tools=not args.no_tools,
+ max_tool_rounds=args.max_tool_rounds,
)
total_usage.add(call_usage)
all_reviews.append((patch_label, review_text))
@@ -1149,6 +1167,8 @@ def main() -> None:
args.output_format,
args.verbose,
args.timeout,
+ enable_tools=not args.no_tools,
+ max_tool_rounds=args.max_tool_rounds,
)
total_usage.add(call_usage)
all_reviews.append((chunk_label, review_text))
@@ -1203,6 +1223,8 @@ def main() -> None:
args.output_format,
args.verbose,
args.timeout,
+ enable_tools=not args.no_tools,
+ max_tool_rounds=args.max_tool_rounds,
)
total_usage.add(call_usage)
--
2.55.0
^ permalink raw reply related [flat|nested] 6+ messages in thread
end of thread, other threads:[~2026-09-09 14:14 UTC | newest]
Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-09 18:26 [RFC] devtools: add tool calling support to review-patch.py Aaron Conole
2026-06-15 11:34 ` David Marchand
2026-09-09 14:10 ` Aaron Conole
2026-06-16 22:42 ` Stephen Hemminger
2026-09-09 14:12 ` Aaron Conole
2026-09-09 14:13 ` [PATCH] devtools: add read-only tool calling support to AI review scripts Aaron Conole
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox