* [PATCH v2] devtools: support local opencode agent for patch review
[not found] <20260703091902.525837-1-datshan@qq.com>
@ 2026-07-06 6:29 ` datshan
2026-07-25 8:43 ` [PATCH v3] " datshan
` (2 subsequent siblings)
3 siblings, 0 replies; 8+ messages in thread
From: datshan @ 2026-07-06 6:29 UTC (permalink / raw)
To: thomas, stephen; +Cc: aconole, dev
From: Chengwen Feng <fengchengwen@huawei.com>
Currently review-patch.py only supports cloud AI providers
(Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
Add a --via option that invokes the locally installed opencode CLI as
the review runner instead of making HTTP calls. opencode reads
AGENTS.md from the DPDK project directory automatically, needing no
configuration beyond opencode on PATH.
The --via and -p/--provider options are independent -- via routes to
the local agent mode while -p continues to use the cloud API path.
Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
---
v2: Address comments from Stephen, including: not arise FileNotFoundError
and more clarify.
---
devtools/ai/review-patch.py | 231 ++++++++++++++++++++++++++----------
1 file changed, 169 insertions(+), 62 deletions(-)
diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 52601ac156..5d023ee689 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -3,9 +3,10 @@
# Copyright(c) 2026 Stephen Hemminger
"""
-Review DPDK patches using AI providers.
+Review DPDK patches using AI providers or a local agent tool.
Supported providers: Anthropic Claude, OpenAI ChatGPT, xAI Grok, Google Gemini
+Supported agent: OpenCode (--via opencode)
"""
import argparse
@@ -551,6 +552,109 @@ def build_google_request(
}
+def _call_opencode(
+ model: str,
+ system_prompt: str,
+ patch_content: str,
+ patch_name: str,
+ agents_path: str = "",
+ output_format: str = "text",
+ verbose: bool = False,
+ timeout: int = 300,
+) -> tuple[str, TokenUsage]:
+ """Call local opencode CLI for review."""
+ format_instruction = FORMAT_INSTRUCTIONS.get(output_format, "")
+ user_prompt = (
+ f"Review the attached DPDK patch file '{patch_name}'.\n\n"
+ f"Focus on correctness bugs, C coding style, API requirements, "
+ f"and other guideline violations. "
+ f"Commit message format and SPDX/copyright are checked by "
+ f"checkpatches.sh -- do NOT flag those.\n\n"
+ f"{format_instruction}"
+ )
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".patch", delete=False, prefix="review_"
+ ) as f:
+ f.write(patch_content)
+ patch_temp = f.name
+
+ try:
+ full_message = system_prompt + "\n\n" + user_prompt
+
+ cmd = [
+ "opencode", "run", "--format", "json",
+ "--dir", str(Path(__file__).resolve().parent.parent.parent),
+ ]
+ if model:
+ cmd.extend(["--model", model])
+ if verbose:
+ cmd.append("--print-logs")
+ cmd.append(full_message)
+ cmd.extend(["--file", patch_temp])
+ if agents_path:
+ cmd.extend(["--file", agents_path])
+
+ if verbose:
+ print(f"Running: {' '.join(cmd)}", file=sys.stderr)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError:
+ error("opencode not found. Install from https://opencode.ai")
+ except subprocess.TimeoutExpired:
+ error(f"opencode timed out after {timeout} seconds")
+
+ if result.returncode != 0:
+ error(
+ f"opencode exited with code {result.returncode}: "
+ f"{result.stderr[:500]}"
+ )
+
+ finally:
+ os.unlink(patch_temp)
+
+ text_parts = []
+ usage = TokenUsage()
+ steps = 0
+ for line in result.stdout.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ try:
+ event = json.loads(stripped)
+ except json.JSONDecodeError:
+ continue
+
+ event_type = event.get("type", "")
+ if event_type == "text":
+ part_text = event.get("part", {}).get("text", "")
+ if part_text:
+ text_parts.append(part_text)
+ elif event_type == "step_finish":
+ steps += 1
+ tokens = event.get("part", {}).get("tokens", {})
+ if tokens:
+ usage.input_tokens += tokens.get("input", 0)
+ usage.output_tokens += tokens.get("output", 0)
+ cache = tokens.get("cache", {})
+ usage.cache_creation_tokens += cache.get("write", 0)
+ usage.cache_read_tokens += cache.get("read", 0)
+
+ usage.api_calls = 1 if steps > 0 else 0
+ review_text = "\n".join(text_parts)
+
+ if not review_text:
+ error("No review text received from opencode")
+
+ return review_text, usage
+
+
def call_api(
provider: str,
api_key: str,
@@ -740,6 +844,7 @@ def main() -> None:
Examples:
%(prog)s patch.patch # Review with default settings
%(prog)s -p openai my-patch.patch # Use OpenAI ChatGPT
+ %(prog)s --via opencode my-patch.patch # Use local opencode agent
%(prog)s -f markdown patch.patch # Output as Markdown
%(prog)s -f json -o review.json patch.patch # Save JSON to file
%(prog)s -f html -o review.html patch.patch # Save HTML to file
@@ -786,7 +891,13 @@ def main() -> None:
"--provider",
choices=PROVIDERS.keys(),
default="anthropic",
- help="AI provider (default: anthropic)",
+ help="Cloud AI provider (default: anthropic)",
+ )
+ parser.add_argument(
+ "--via",
+ choices=["opencode"],
+ default=None,
+ help="Use a local agent tool instead of a cloud API (e.g. --via opencode)",
)
parser.add_argument(
"-a",
@@ -926,14 +1037,19 @@ def main() -> None:
if not args.patch_file:
parser.error("patch_file is required")
- # Get provider config
- config = PROVIDERS[args.provider]
- model = args.model or config["default_model"]
-
- # Get API key
- api_key = os.environ.get(config["env_var"])
- if not api_key:
- error(f"{config['env_var']} environment variable not set")
+ # Get provider config or set up local agent runner
+ via = args.via
+ if via:
+ model = args.model or ""
+ api_key = ""
+ provider_name = "OpenCode"
+ else:
+ config = PROVIDERS[args.provider]
+ model = args.model or config["default_model"]
+ api_key = os.environ.get(config["env_var"])
+ if not api_key:
+ error(f"{config['env_var']} environment variable not set")
+ provider_name = config["name"]
# Validate files
agents_path = Path(args.agents)
@@ -971,17 +1087,42 @@ def main() -> None:
patch_content = patch_path.read_text(encoding="utf-8", errors="replace")
patch_name = patch_path.name
- # Determine max tokens for this provider
- max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
- args.provider, 100000
- )
+ # Dispatch to agent or provider
+ def _run_review(patch_body: str, patch_label: str) -> tuple[str, TokenUsage]:
+ if via:
+ return _call_opencode(
+ model, system_prompt,
+ patch_body, patch_label,
+ str(agents_path),
+ args.output_format, args.verbose, args.timeout,
+ )
+ return call_api(
+ args.provider, api_key, model, args.tokens,
+ system_prompt, agents_content,
+ patch_body, patch_label,
+ args.output_format, args.verbose, args.timeout,
+ )
- # Estimate token count
- estimated_tokens = estimate_tokens(patch_content + agents_content)
+ # Determine max tokens (cloud API only)
+ max_input_tokens = 0
+ if via:
+ estimated_tokens = 1
+ else:
+ max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
+ args.provider, 100000
+ )
+ estimated_tokens = estimate_tokens(patch_content + agents_content)
# Accumulate token usage across all API calls
total_usage = TokenUsage()
+ if via and args.large_file != "error":
+ print(
+ "Warning: --large-file is ignored in --via mode; "
+ "opencode handles large files automatically",
+ file=sys.stderr,
+ )
+
# Parse patch range if specified
patch_start, patch_end = None, None
if args.patch_range:
@@ -1039,19 +1180,7 @@ def main() -> None:
patch_label = f"Patch {i}/{total_patches}"
print(f"\nReviewing {patch_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- api_key,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch,
- f"{patch_name} ({patch_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ review_text, call_usage = _run_review(patch, f"{patch_name} ({patch_label})")
total_usage.add(call_usage)
all_reviews.append((patch_label, review_text))
@@ -1063,10 +1192,10 @@ def main() -> None:
# Skip the normal API call
estimated_tokens = 0 # Bypass size check since we've already processed
- # Check if content is too large
+ # Check if content is too large (cloud API only)
is_large = estimated_tokens > max_input_tokens
- if is_large:
+ if is_large and not via:
print(
f"Warning: Estimated {estimated_tokens:,} tokens exceeds limit of "
f"{max_input_tokens:,}",
@@ -1109,19 +1238,7 @@ def main() -> None:
chunk_label = f"Chunk {chunk_num}/{total_chunks}"
print(f"Reviewing {chunk_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- api_key,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- chunk,
- f"{patch_name} ({chunk_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ review_text, call_usage = _run_review(chunk, f"{patch_name} ({chunk_label})")
total_usage.add(call_usage)
all_reviews.append((chunk_label, review_text))
@@ -1135,7 +1252,10 @@ def main() -> None:
if args.verbose:
print("=== Request ===", file=sys.stderr)
- print(f"Provider: {args.provider}", file=sys.stderr)
+ if via:
+ print(f"Runner: {args.via}", file=sys.stderr)
+ else:
+ print(f"Provider: {args.provider}", file=sys.stderr)
print(f"Model: {model}", file=sys.stderr)
print(f"Review date: {review_date}", file=sys.stderr)
if args.release:
@@ -1162,26 +1282,13 @@ def main() -> None:
# Call API (unless already processed via chunks/split)
if estimated_tokens > 0: # Not already processed
- review_text, call_usage = call_api(
- args.provider,
- api_key,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch_content,
- patch_name,
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ review_text, call_usage = _run_review(patch_content, patch_name)
total_usage.add(call_usage)
if not review_text:
- error(f"No response received from {args.provider}")
+ error(f"No response received from {provider_name}")
# Format output based on requested format
- provider_name = config["name"]
if args.output_format == "json":
# For JSON, try to parse and add metadata
@@ -1205,7 +1312,7 @@ def main() -> None:
output_data = {
"metadata": {
"patch_file": patch_name,
- "provider": args.provider,
+ "provider": args.via or args.provider,
"provider_name": provider_name,
"model": model,
"review_date": review_date,
@@ -1260,7 +1367,7 @@ def main() -> None:
print_token_summary(
total_usage,
- args.provider,
+ args.via or args.provider,
model,
args.show_tokens or args.verbose,
)
--
2.54.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v3] devtools: support local opencode agent for patch review
[not found] <20260703091902.525837-1-datshan@qq.com>
2026-07-06 6:29 ` [PATCH v2] devtools: support local opencode agent for patch review datshan
@ 2026-07-25 8:43 ` datshan
2026-08-04 8:41 ` fengchengwen
` (2 more replies)
2026-08-05 6:12 ` [PATCH v4] " datshan
2026-08-07 3:11 ` [PATCH v5] " Chengwen Feng
3 siblings, 3 replies; 8+ messages in thread
From: datshan @ 2026-07-25 8:43 UTC (permalink / raw)
To: thomas, stephen; +Cc: aconole, dev
From: Chengwen Feng <fengchengwen@huawei.com>
Currently review-patch.py only supports cloud AI providers
(Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
Add a --via option that invokes the locally installed opencode CLI as
the review runner instead of making HTTP calls. opencode reads
AGENTS.md from the DPDK project directory automatically, needing no
configuration beyond opencode on PATH.
The --via and -p/--provider options are independent -- via routes to
the local agent mode while -p continues to use the cloud API path.
Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
---
v3: Rebase main to fix apply error
v2: Address comments from Stephen, including: not arise
FileNotFoundError and more clarify.
---
devtools/ai/review-patch.py | 229 ++++++++++++++++++++++++++----------
1 file changed, 168 insertions(+), 61 deletions(-)
diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 5f8d9ed772..7ee34cfe6d 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -3,9 +3,10 @@
# Copyright(c) 2026 Stephen Hemminger
"""
-Review DPDK patches using AI providers.
+Review DPDK patches using AI providers or a local agent tool.
Supported providers: Anthropic Claude, OpenAI ChatGPT, xAI Grok, Google Gemini
+Supported agent: OpenCode (--via opencode)
"""
import argparse
@@ -577,6 +578,109 @@ def build_google_request(
}
+def _call_opencode(
+ model: str,
+ system_prompt: str,
+ patch_content: str,
+ patch_name: str,
+ agents_path: str = "",
+ output_format: str = "text",
+ verbose: bool = False,
+ timeout: int = 300,
+) -> tuple[str, TokenUsage]:
+ """Call local opencode CLI for review."""
+ format_instruction = FORMAT_INSTRUCTIONS.get(output_format, "")
+ user_prompt = (
+ f"Review the attached DPDK patch file '{patch_name}'.\n\n"
+ f"Focus on correctness bugs, C coding style, API requirements, "
+ f"and other guideline violations. "
+ f"Commit message format and SPDX/copyright are checked by "
+ f"checkpatches.sh -- do NOT flag those.\n\n"
+ f"{format_instruction}"
+ )
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".patch", delete=False, prefix="review_"
+ ) as f:
+ f.write(patch_content)
+ patch_temp = f.name
+
+ try:
+ full_message = system_prompt + "\n\n" + user_prompt
+
+ cmd = [
+ "opencode", "run", "--format", "json",
+ "--dir", str(Path(__file__).resolve().parent.parent.parent),
+ ]
+ if model:
+ cmd.extend(["--model", model])
+ if verbose:
+ cmd.append("--print-logs")
+ cmd.append(full_message)
+ cmd.extend(["--file", patch_temp])
+ if agents_path:
+ cmd.extend(["--file", agents_path])
+
+ if verbose:
+ print(f"Running: {' '.join(cmd)}", file=sys.stderr)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError:
+ error("opencode not found. Install from https://opencode.ai")
+ except subprocess.TimeoutExpired:
+ error(f"opencode timed out after {timeout} seconds")
+
+ if result.returncode != 0:
+ error(
+ f"opencode exited with code {result.returncode}: "
+ f"{result.stderr[:500]}"
+ )
+
+ finally:
+ os.unlink(patch_temp)
+
+ text_parts = []
+ usage = TokenUsage()
+ steps = 0
+ for line in result.stdout.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ try:
+ event = json.loads(stripped)
+ except json.JSONDecodeError:
+ continue
+
+ event_type = event.get("type", "")
+ if event_type == "text":
+ part_text = event.get("part", {}).get("text", "")
+ if part_text:
+ text_parts.append(part_text)
+ elif event_type == "step_finish":
+ steps += 1
+ tokens = event.get("part", {}).get("tokens", {})
+ if tokens:
+ usage.input_tokens += tokens.get("input", 0)
+ usage.output_tokens += tokens.get("output", 0)
+ cache = tokens.get("cache", {})
+ usage.cache_creation_tokens += cache.get("write", 0)
+ usage.cache_read_tokens += cache.get("read", 0)
+
+ usage.api_calls = 1 if steps > 0 else 0
+ review_text = "\n".join(text_parts)
+
+ if not review_text:
+ error("No review text received from opencode")
+
+ return review_text, usage
+
+
def call_api(
provider: str,
auth: str,
@@ -764,6 +868,7 @@ def main() -> None:
Examples:
%(prog)s patch.patch # Review with default settings
%(prog)s -p openai my-patch.patch # Use OpenAI ChatGPT
+ %(prog)s --via opencode my-patch.patch # Use local opencode agent
%(prog)s -f markdown patch.patch # Output as Markdown
%(prog)s -f json -o review.json patch.patch # Save JSON to file
%(prog)s -f html -o review.html patch.patch # Save HTML to file
@@ -810,7 +915,13 @@ def main() -> None:
"--provider",
choices=PROVIDERS.keys(),
default="anthropic",
- help="AI provider (default: anthropic)",
+ help="Cloud AI provider (default: anthropic)",
+ )
+ parser.add_argument(
+ "--via",
+ choices=["opencode"],
+ default=None,
+ help="Use a local agent tool instead of a cloud API (e.g. --via opencode)",
)
parser.add_argument(
"-a",
@@ -956,12 +1067,17 @@ def main() -> None:
if not args.patch_file:
parser.error("patch_file is required")
- # Get provider config
- config = PROVIDERS[args.provider]
- model = args.model or config["default_model"]
-
- # Get authentication string
- auth = get_auth_string(args.auth, args.provider)
+ # Get provider config or set up local agent runner
+ via = args.via
+ if via:
+ model = args.model or ""
+ auth = ""
+ provider_name = "OpenCode"
+ else:
+ config = PROVIDERS[args.provider]
+ model = args.model or config["default_model"]
+ auth = get_auth_string(args.auth, args.provider)
+ provider_name = config["name"]
# Validate files
agents_path = Path(args.agents)
@@ -999,17 +1115,42 @@ def main() -> None:
patch_content = patch_path.read_text(encoding="utf-8", errors="replace")
patch_name = patch_path.name
- # Determine max tokens for this provider
- max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
- args.provider, 100000
- )
+ # Dispatch to agent or provider
+ def _run_review(patch_body: str, patch_label: str) -> tuple[str, TokenUsage]:
+ if via:
+ return _call_opencode(
+ model, system_prompt,
+ patch_body, patch_label,
+ str(agents_path),
+ args.output_format, args.verbose, args.timeout,
+ )
+ return call_api(
+ args.provider, auth, model, args.tokens,
+ system_prompt, agents_content,
+ patch_body, patch_label,
+ args.output_format, args.verbose, args.timeout,
+ )
- # Estimate token count
- estimated_tokens = estimate_tokens(patch_content + agents_content)
+ # Determine max tokens (cloud API only)
+ max_input_tokens = 0
+ if via:
+ estimated_tokens = 1
+ else:
+ max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
+ args.provider, 100000
+ )
+ estimated_tokens = estimate_tokens(patch_content + agents_content)
# Accumulate token usage across all API calls
total_usage = TokenUsage()
+ if via and args.large_file != "error":
+ print(
+ "Warning: --large-file is ignored in --via mode; "
+ "opencode handles large files automatically",
+ file=sys.stderr,
+ )
+
# Parse patch range if specified
patch_start, patch_end = None, None
if args.patch_range:
@@ -1067,19 +1208,7 @@ def main() -> None:
patch_label = f"Patch {i}/{total_patches}"
print(f"\nReviewing {patch_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch,
- f"{patch_name} ({patch_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ review_text, call_usage = _run_review(patch, f"{patch_name} ({patch_label})")
total_usage.add(call_usage)
all_reviews.append((patch_label, review_text))
@@ -1091,10 +1220,10 @@ def main() -> None:
# Skip the normal API call
estimated_tokens = 0 # Bypass size check since we've already processed
- # Check if content is too large
+ # Check if content is too large (cloud API only)
is_large = estimated_tokens > max_input_tokens
- if is_large:
+ if is_large and not via:
print(
f"Warning: Estimated {estimated_tokens:,} tokens exceeds limit of "
f"{max_input_tokens:,}",
@@ -1137,19 +1266,7 @@ def main() -> None:
chunk_label = f"Chunk {chunk_num}/{total_chunks}"
print(f"Reviewing {chunk_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- chunk,
- f"{patch_name} ({chunk_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ review_text, call_usage = _run_review(chunk, f"{patch_name} ({chunk_label})")
total_usage.add(call_usage)
all_reviews.append((chunk_label, review_text))
@@ -1163,8 +1280,11 @@ def main() -> None:
if args.verbose:
print("=== Request ===", file=sys.stderr)
- print(f"Provider: {args.provider}", file=sys.stderr)
- print(f"Auth method: {'vertex' if auth == 'vertex' else 'direct'}", file=sys.stderr)
+ if via:
+ print(f"Runner: {args.via}", file=sys.stderr)
+ else:
+ print(f"Provider: {args.provider}", file=sys.stderr)
+ print(f"Auth method: {'vertex' if auth == 'vertex' else 'direct'}", file=sys.stderr)
print(f"Model: {model}", file=sys.stderr)
print(f"Review date: {review_date}", file=sys.stderr)
if args.release:
@@ -1191,26 +1311,13 @@ def main() -> None:
# Call API (unless already processed via chunks/split)
if estimated_tokens > 0: # Not already processed
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch_content,
- patch_name,
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ review_text, call_usage = _run_review(patch_content, patch_name)
total_usage.add(call_usage)
if not review_text:
- error(f"No response received from {args.provider}")
+ error(f"No response received from {provider_name}")
# Format output based on requested format
- provider_name = config["name"]
if args.output_format == "json":
# For JSON, try to parse and add metadata
@@ -1234,7 +1341,7 @@ def main() -> None:
output_data = {
"metadata": {
"patch_file": patch_name,
- "provider": args.provider,
+ "provider": args.via or args.provider,
"provider_name": provider_name,
"model": model,
"review_date": review_date,
@@ -1289,7 +1396,7 @@ def main() -> None:
print_token_summary(
total_usage,
- args.provider,
+ args.via or args.provider,
model,
args.show_tokens or args.verbose,
)
--
2.54.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* Re: [PATCH v3] devtools: support local opencode agent for patch review
2026-07-25 8:43 ` [PATCH v3] " datshan
@ 2026-08-04 8:41 ` fengchengwen
2026-08-04 12:22 ` Morten Brørup
2026-08-04 16:18 ` Stephen Hemminger
2 siblings, 0 replies; 8+ messages in thread
From: fengchengwen @ 2026-08-04 8:41 UTC (permalink / raw)
To: datshan, thomas, stephen; +Cc: aconole, dev
Ping for review
Thanks
On 7/25/2026 4:43 PM, datshan@qq.com wrote:
> From: Chengwen Feng <fengchengwen@huawei.com>
>
> Currently review-patch.py only supports cloud AI providers
> (Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
>
> Add a --via option that invokes the locally installed opencode CLI as
> the review runner instead of making HTTP calls. opencode reads
> AGENTS.md from the DPDK project directory automatically, needing no
> configuration beyond opencode on PATH.
>
> The --via and -p/--provider options are independent -- via routes to
> the local agent mode while -p continues to use the cloud API path.
>
> Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
^ permalink raw reply [flat|nested] 8+ messages in thread
* RE: [PATCH v3] devtools: support local opencode agent for patch review
2026-07-25 8:43 ` [PATCH v3] " datshan
2026-08-04 8:41 ` fengchengwen
@ 2026-08-04 12:22 ` Morten Brørup
2026-08-04 16:18 ` Stephen Hemminger
2 siblings, 0 replies; 8+ messages in thread
From: Morten Brørup @ 2026-08-04 12:22 UTC (permalink / raw)
To: datshan, thomas, stephen; +Cc: aconole, dev
> From: datshan@qq.com [mailto:datshan@qq.com]
> Sent: Saturday, 25 July 2026 10.44
> From: Chengwen Feng <fengchengwen@huawei.com>
>
> Currently review-patch.py only supports cloud AI providers
> (Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
>
> Add a --via option that invokes the locally installed opencode CLI as
> the review runner instead of making HTTP calls. opencode reads
> AGENTS.md from the DPDK project directory automatically, needing no
> configuration beyond opencode on PATH.
>
> The --via and -p/--provider options are independent -- via routes to
> the local agent mode while -p continues to use the cloud API path.
>
> Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
>
> ---
> v3: Rebase main to fix apply error
> v2: Address comments from Stephen, including: not arise
> FileNotFoundError and more clarify.
> ---
I have only skimmed this, but
the concept of local agent support deserves a big fat:
Acked-by: Morten Brørup <mb@smartsharesystems.com>
^ permalink raw reply [flat|nested] 8+ messages in thread
* Re: [PATCH v3] devtools: support local opencode agent for patch review
2026-07-25 8:43 ` [PATCH v3] " datshan
2026-08-04 8:41 ` fengchengwen
2026-08-04 12:22 ` Morten Brørup
@ 2026-08-04 16:18 ` Stephen Hemminger
2 siblings, 0 replies; 8+ messages in thread
From: Stephen Hemminger @ 2026-08-04 16:18 UTC (permalink / raw)
To: datshan; +Cc: thomas, aconole, dev
On Sat, 25 Jul 2026 16:43:45 +0800
datshan@qq.com wrote:
> From: Chengwen Feng <fengchengwen@huawei.com>
>
> Currently review-patch.py only supports cloud AI providers
> (Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
>
> Add a --via option that invokes the locally installed opencode CLI as
> the review runner instead of making HTTP calls. opencode reads
> AGENTS.md from the DPDK project directory automatically, needing no
> configuration beyond opencode on PATH.
>
> The --via and -p/--provider options are independent -- via routes to
> the local agent mode while -p continues to use the cloud API path.
>
> Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
>
> ---
Good idea, AI review with Claude Opus found:
Errors:
1. opencode "error" events are silently dropped.
The parse loop handles only "text" and "step_finish". The event
stream also emits {"type":"error","error":{"name":...,
"data":{"message":...}}} for rate limits and provider failures.
If a session errors after emitting some text, the partial review
is returned as if complete, and classify_review() will exit 0.
A review tool reporting "clean" on an aborted run is the worst
failure mode here. Handle it:
elif event_type == "error":
err = event.get("error", {})
error(f"opencode: {err.get('name', '')}: "
f"{err.get('data', {}).get('message', '')}")
2. All text parts are concatenated, including intermediate narration.
Unlike a single-shot API call, an agent interleaves text with tool
calls, so text_parts collects the model's running commentary
("Let me read AGENTS.md first...") along with the actual review.
That commentary ends up in the output file, in the --send-email
body, and is scanned by classify_review() -- a narration line
starting with "Error" flips the exit code to 3.
Keep only the final assistant message: track part.messageID and
emit the parts whose messageID matches the step_finish that has
reason == "stop".
Warnings:
3. agents_path is passed to --file unresolved.
patch_temp is absolute (tempfile), but str(agents_path) is
whatever the user gave and defaults to the relative "AGENTS.md",
while opencode is launched with --dir pointing at the repo root
rather than the caller's cwd. Use str(agents_path.resolve()).
Related: the commit message says opencode picks up AGENTS.md from
the project directory automatically. If so, --file agents_path is
redundant in the default case, and with -a it adds a second file
rather than replacing the project one -- so -a does not mean what
it means on the cloud path. Pick one behaviour and state it.
4. Verbose logs are captured and discarded.
-v appends --print-logs, which writes to stderr, but
capture_output=True swallows stderr and it is only surfaced
(truncated to 500 chars) on non-zero exit. So --via -v gives the
user none of the logs it just asked for. Either drop
--print-logs or pass stderr=None when verbose.
5. Failure diagnostics are thrown away.
JSONDecodeError does "continue", so if stdout is ever not JSONL
(schema change, older opencode, a wrapper printing a banner)
every line is skipped and the user gets only "No review text
received from opencode". Include the first few hundred chars of
stdout in that error message.
6. Options silently ignored in --via mode.
-p/--provider, --auth, -t/--tokens and --max-tokens have no
effect; only --large-file warns. Since -p has a default you
cannot tell "unset" from "explicitly set" -- give it
default=None and resolve to anthropic later, then parser.error()
when --via is combined with an explicit -p or --auth.
The commit message's claim that "--via and -p/--provider are
independent -- ... -p continues to use the cloud API path" is not
what the code does: --via wins and -p is dropped.
7. Documentation not updated in the same patch.
doc/guides/contributing/patches.rst, "AI-Assisted Patch Review",
states the script supports four providers and that an API key must
be set. --via opencode is user-visible and removes that
requirement. Code and docs go in the same commit.
8. The agent runs with the default toolset in the source tree.
opencode run --dir <dpdk root> with no --agent or permission
restriction gives the reviewing agent write/edit/bash on the
working tree. A review should not be able to modify the tree it
is reviewing. Restrict it to a read/grep/glob agent, or document
the exposure.
9. black reformats the new code in four places: the cmd list literal,
the argument packing in _run_review(), and lines 1211 and 1269
(93 chars each). Line 1287 is pre-existing but this patch
re-indents it, so it may as well be wrapped now.
Info:
10. estimated_tokens = 1 / max_input_tokens = 0 as a sentinel so that
"if estimated_tokens > 0" means "not already reviewed" is
fragile. An explicit already_reviewed flag reads better.
11. usage.api_calls = 1 if steps > 0 else 0 -- an agent run is
"steps" calls, not one, and print_token_summary() suppresses the
entire summary when api_calls == 0, so a run with text but no
step_finish prints nothing under --show-tokens. Use
usage.api_calls = steps.
12. Metadata is inconsistent: provider_name is "OpenCode" while the
JSON "provider" field gets args.via, i.e. "opencode".
13. compare-patch-reviews.sh only iterates providers that have API
keys and has no way to include the local runner. Worth a
follow-up if comparing local against cloud is the point.
^ permalink raw reply [flat|nested] 8+ messages in thread
* [PATCH v4] devtools: support local opencode agent for patch review
[not found] <20260703091902.525837-1-datshan@qq.com>
2026-07-06 6:29 ` [PATCH v2] devtools: support local opencode agent for patch review datshan
2026-07-25 8:43 ` [PATCH v3] " datshan
@ 2026-08-05 6:12 ` datshan
2026-08-07 3:11 ` [PATCH v5] " Chengwen Feng
3 siblings, 0 replies; 8+ messages in thread
From: datshan @ 2026-08-05 6:12 UTC (permalink / raw)
To: thomas, stephen; +Cc: aconole, dev
From: Chengwen Feng <fengchengwen@huawei.com>
Currently review-patch.py only supports cloud AI providers
(Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
Add a --via option that invokes the locally installed opencode CLI as
the review runner instead of making HTTP calls. opencode reads
AGENTS.md from the DPDK project directory automatically, needing no
configuration beyond opencode on PATH.
The --via and -p/--provider options are independent -- via routes to
the local agent mode while -p continues to use the cloud API path.
Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
Acked-by: Morten Brørup <mb@smartsharesystems.com>
---
v4: Fix AI review comments with Claude Opus which provided by Stephen
v3: Rebase main to fix apply error
v2: Address comments from Stephen, including: not arise
FileNotFoundError and more clarify.
---
devtools/ai/review-patch.py | 305 ++++++++++++++++++++++------
doc/guides/contributing/patches.rst | 10 +
2 files changed, 248 insertions(+), 67 deletions(-)
diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 5f8d9ed772..c8d81a3333 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -3,9 +3,10 @@
# Copyright(c) 2026 Stephen Hemminger
"""
-Review DPDK patches using AI providers.
+Review DPDK patches using AI providers or a local agent tool.
Supported providers: Anthropic Claude, OpenAI ChatGPT, xAI Grok, Google Gemini
+Supported agent: OpenCode (--via opencode)
"""
import argparse
@@ -577,6 +578,160 @@ def build_google_request(
}
+def _call_opencode(
+ model: str,
+ system_prompt: str,
+ patch_content: str,
+ patch_name: str,
+ agents_path: str = "",
+ output_format: str = "text",
+ verbose: bool = False,
+ timeout: int = 300,
+) -> tuple[str, TokenUsage]:
+ """Call local opencode CLI for review.
+
+ Note: opencode runs with its default agent toolset, which includes
+ write/edit/bash against the working tree (--dir points at the DPDK
+ root). A review should ideally be read-only; restricting the toolset
+ requires opencode to gain a --read-only or --agent flag, which it
+ does not currently expose. Until then, be aware that the reviewing
+ agent can in principle modify the tree it is reviewing.
+ """
+ format_instruction = FORMAT_INSTRUCTIONS.get(output_format, "")
+ user_prompt = (
+ f"Review the attached DPDK patch file '{patch_name}'.\n\n"
+ f"Focus on correctness bugs, C coding style, API requirements, "
+ f"and other guideline violations. "
+ f"Commit message format and SPDX/copyright are checked by "
+ f"checkpatches.sh -- do NOT flag those.\n\n"
+ f"{format_instruction}"
+ )
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".patch", delete=False, prefix="review_"
+ ) as f:
+ f.write(patch_content)
+ patch_temp = f.name
+
+ try:
+ full_message = system_prompt + "\n\n" + user_prompt
+
+ # opencode auto-loads AGENTS.md from --dir; -a is only meaningful
+ # when pointing at a non-default file. Resolve to an absolute
+ # path because --dir differs from the caller's cwd.
+ agents_abs = str(Path(agents_path).resolve()) if agents_path else ""
+
+ cmd = [
+ "opencode",
+ "run",
+ "--format",
+ "json",
+ "--dir",
+ str(Path(__file__).resolve().parent.parent.parent),
+ ]
+ if model:
+ cmd.extend(["--model", model])
+ # In verbose mode, let opencode's own logs reach the user
+ # instead of capturing and discarding them.
+ if verbose:
+ cmd.append("--print-logs")
+ cmd.append(full_message)
+ cmd.extend(["--file", patch_temp])
+ if agents_abs:
+ cmd.extend(["--file", agents_abs])
+
+ if verbose:
+ print(f"Running: {' '.join(cmd)}", file=sys.stderr)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=None if verbose else subprocess.PIPE,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError:
+ error("opencode not found. Install from https://opencode.ai")
+ except subprocess.TimeoutExpired:
+ error(f"opencode timed out after {timeout} seconds")
+
+ if result.returncode != 0:
+ stderr = result.stderr or ""
+ error(
+ f"opencode exited with code {result.returncode}: "
+ f"{stderr[:500]}"
+ )
+
+ finally:
+ os.unlink(patch_temp)
+
+ stdout = result.stdout or ""
+ # Buffer text per messageID so we can keep only the final assistant
+ # turn (the actual review) and drop intermediate narration such as
+ # "Let me read AGENTS.md first...".
+ message_text: dict[str, list[str]] = {}
+ message_order: list[str] = []
+ final_message_id = ""
+ usage = TokenUsage()
+ steps = 0
+
+ for line in stdout.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ try:
+ event = json.loads(stripped)
+ except json.JSONDecodeError:
+ continue
+
+ event_type = event.get("type", "")
+ part = event.get("part", {})
+ if event_type == "text":
+ mid = part.get("messageID", "")
+ if mid:
+ if mid not in message_text:
+ message_text[mid] = []
+ message_order.append(mid)
+ message_text[mid].append(part.get("text", ""))
+ elif event_type == "step_finish":
+ steps += 1
+ if part.get("reason") == "stop":
+ final_message_id = part.get("messageID", "") or final_message_id
+ tokens = part.get("tokens", {})
+ if tokens:
+ usage.input_tokens += tokens.get("input", 0)
+ usage.output_tokens += tokens.get("output", 0)
+ cache = tokens.get("cache", {})
+ usage.cache_creation_tokens += cache.get("write", 0)
+ usage.cache_read_tokens += cache.get("read", 0)
+ elif event_type == "error":
+ err = event.get("error", {})
+ err_name = err.get("name", "unknown")
+ err_msg = err.get("data", {}).get("message", "")
+ error(f"opencode: {err_name}: {err_msg}")
+
+ usage.api_calls = steps
+
+ if final_message_id and final_message_id in message_text:
+ review_text = "\n".join(message_text[final_message_id])
+ elif message_order:
+ # No explicit stop marker; fall back to the last message that
+ # produced text.
+ review_text = "\n".join(message_text[message_order[-1]])
+ else:
+ review_text = ""
+
+ if not review_text:
+ snippet = stdout[:300].replace("\n", " ")
+ error(
+ f"No review text received from opencode; "
+ f"stdout begins: {snippet!r}"
+ )
+
+ return review_text, usage
+
+
def call_api(
provider: str,
auth: str,
@@ -764,6 +919,7 @@ def main() -> None:
Examples:
%(prog)s patch.patch # Review with default settings
%(prog)s -p openai my-patch.patch # Use OpenAI ChatGPT
+ %(prog)s --via opencode my-patch.patch # Use local opencode agent
%(prog)s -f markdown patch.patch # Output as Markdown
%(prog)s -f json -o review.json patch.patch # Save JSON to file
%(prog)s -f html -o review.html patch.patch # Save HTML to file
@@ -809,8 +965,14 @@ def main() -> None:
"-p",
"--provider",
choices=PROVIDERS.keys(),
- default="anthropic",
- help="AI provider (default: anthropic)",
+ default=None,
+ help="Cloud AI provider (default: anthropic)",
+ )
+ parser.add_argument(
+ "--via",
+ choices=["opencode"],
+ default=None,
+ help="Use a local agent tool instead of a cloud API (e.g. --via opencode)",
)
parser.add_argument(
"-a",
@@ -956,12 +1118,26 @@ def main() -> None:
if not args.patch_file:
parser.error("patch_file is required")
- # Get provider config
- config = PROVIDERS[args.provider]
- model = args.model or config["default_model"]
-
- # Get authentication string
- auth = get_auth_string(args.auth, args.provider)
+ # --via and -p/--auth are mutually exclusive; -p defaults to anthropic
+ # when neither --via nor -p is given. Detect explicit -p/--auth by
+ # comparing against the original parser default of None.
+ via = args.via
+ if via and args.provider is not None:
+ parser.error("--via and -p/--provider are mutually exclusive")
+ if via and args.auth != "auto":
+ parser.error("--via and --auth are mutually exclusive")
+ provider = args.provider or "anthropic"
+
+ # Get provider config or set up local agent runner
+ if via:
+ model = args.model or ""
+ auth = ""
+ provider_name = "OpenCode"
+ else:
+ config = PROVIDERS[provider]
+ model = args.model or config["default_model"]
+ auth = get_auth_string(args.auth, provider)
+ provider_name = config["name"]
# Validate files
agents_path = Path(args.agents)
@@ -999,17 +1175,45 @@ def main() -> None:
patch_content = patch_path.read_text(encoding="utf-8", errors="replace")
patch_name = patch_path.name
- # Determine max tokens for this provider
- max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
- args.provider, 100000
- )
+ # Dispatch to agent or provider
+ def _run_review(patch_body: str, patch_label: str) -> tuple[str, TokenUsage]:
+ if via:
+ return _call_opencode(
+ model, system_prompt,
+ patch_body, patch_label,
+ str(agents_path),
+ args.output_format, args.verbose, args.timeout,
+ )
+ return call_api(
+ provider, auth, model, args.tokens,
+ system_prompt, agents_content,
+ patch_body, patch_label,
+ args.output_format, args.verbose, args.timeout,
+ )
- # Estimate token count
- estimated_tokens = estimate_tokens(patch_content + agents_content)
+ # Determine max tokens (cloud API only)
+ max_input_tokens = 0
+ estimated_tokens = 0
+ if via:
+ pass
+ else:
+ max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
+ provider, 100000
+ )
+ estimated_tokens = estimate_tokens(patch_content + agents_content)
+
+ already_reviewed = False
# Accumulate token usage across all API calls
total_usage = TokenUsage()
+ if via and args.large_file != "error":
+ print(
+ "Warning: --large-file is ignored in --via mode; "
+ "opencode handles large files automatically",
+ file=sys.stderr,
+ )
+
# Parse patch range if specified
patch_start, patch_end = None, None
if args.patch_range:
@@ -1067,19 +1271,8 @@ def main() -> None:
patch_label = f"Patch {i}/{total_patches}"
print(f"\nReviewing {patch_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch,
- f"{patch_name} ({patch_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ label = f"{patch_name} ({patch_label})"
+ review_text, call_usage = _run_review(patch, label)
total_usage.add(call_usage)
all_reviews.append((patch_label, review_text))
@@ -1088,13 +1281,12 @@ def main() -> None:
all_reviews, args.output_format, patch_name
)
- # Skip the normal API call
- estimated_tokens = 0 # Bypass size check since we've already processed
+ already_reviewed = True
- # Check if content is too large
+ # Check if content is too large (cloud API only)
is_large = estimated_tokens > max_input_tokens
- if is_large:
+ if is_large and not via:
print(
f"Warning: Estimated {estimated_tokens:,} tokens exceeds limit of "
f"{max_input_tokens:,}",
@@ -1137,19 +1329,8 @@ def main() -> None:
chunk_label = f"Chunk {chunk_num}/{total_chunks}"
print(f"Reviewing {chunk_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- chunk,
- f"{patch_name} ({chunk_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ label = f"{patch_name} ({chunk_label})"
+ review_text, call_usage = _run_review(chunk, label)
total_usage.add(call_usage)
all_reviews.append((chunk_label, review_text))
@@ -1158,13 +1339,16 @@ def main() -> None:
all_reviews, args.output_format, patch_name
)
- # Skip the normal single API call below
- estimated_tokens = 0
+ already_reviewed = True
if args.verbose:
print("=== Request ===", file=sys.stderr)
- print(f"Provider: {args.provider}", file=sys.stderr)
- print(f"Auth method: {'vertex' if auth == 'vertex' else 'direct'}", file=sys.stderr)
+ if via:
+ print(f"Runner: {args.via}", file=sys.stderr)
+ else:
+ print(f"Provider: {provider}", file=sys.stderr)
+ method = "vertex" if auth == "vertex" else "direct"
+ print(f"Auth method: {method}", file=sys.stderr)
print(f"Model: {model}", file=sys.stderr)
print(f"Review date: {review_date}", file=sys.stderr)
if args.release:
@@ -1190,27 +1374,14 @@ def main() -> None:
print("===============", file=sys.stderr)
# Call API (unless already processed via chunks/split)
- if estimated_tokens > 0: # Not already processed
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch_content,
- patch_name,
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ if not already_reviewed:
+ review_text, call_usage = _run_review(patch_content, patch_name)
total_usage.add(call_usage)
if not review_text:
- error(f"No response received from {args.provider}")
+ error(f"No response received from {provider_name}")
# Format output based on requested format
- provider_name = config["name"]
if args.output_format == "json":
# For JSON, try to parse and add metadata
@@ -1234,7 +1405,7 @@ def main() -> None:
output_data = {
"metadata": {
"patch_file": patch_name,
- "provider": args.provider,
+ "provider": args.via or provider,
"provider_name": provider_name,
"model": model,
"review_date": review_date,
@@ -1289,7 +1460,7 @@ def main() -> None:
print_token_summary(
total_usage,
- args.provider,
+ args.via or provider,
model,
args.show_tokens or args.verbose,
)
diff --git a/doc/guides/contributing/patches.rst b/doc/guides/contributing/patches.rst
index f4996fd195..bae0a8217a 100644
--- a/doc/guides/contributing/patches.rst
+++ b/doc/guides/contributing/patches.rst
@@ -533,6 +533,13 @@ The script supports multiple AI providers
An API key for the chosen provider must be set
in the corresponding environment variable (see ``--list-providers``).
+Alternatively, the ``--via opencode`` option uses the locally installed
+`opencode <https://opencode.ai>`_ CLI as the review runner instead of
+calling a cloud API directly.
+opencode reads ``AGENTS.md`` from the DPDK project directory
+and selects the model from its own configuration,
+so no API key needs to be set in the environment.
+
Basic usage::
# Review a single patch (default provider: Anthropic Claude)
@@ -541,6 +548,9 @@ Basic usage::
# Use a different provider
devtools/ai/review-patch.py -p openai my-patch.patch
+ # Use the local opencode agent instead of a cloud API
+ devtools/ai/review-patch.py --via opencode my-patch.patch
+
# Review for an LTS branch (enables stricter rules)
devtools/ai/review-patch.py -r 24.11 my-patch.patch
--
2.55.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5] devtools: support local opencode agent for patch review
[not found] <20260703091902.525837-1-datshan@qq.com>
` (2 preceding siblings ...)
2026-08-05 6:12 ` [PATCH v4] " datshan
@ 2026-08-07 3:11 ` Chengwen Feng
2026-08-07 3:32 ` fengchengwen
3 siblings, 1 reply; 8+ messages in thread
From: Chengwen Feng @ 2026-08-07 3:11 UTC (permalink / raw)
To: thomas, stephen; +Cc: aconole, dev
From: Chengwen Feng <fengchengwen@huawei.com>
Currently review-patch.py only supports cloud AI providers
(Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
Add a --via option that invokes the locally installed opencode CLI as
the review runner instead of making HTTP calls. opencode reads
AGENTS.md from the DPDK project directory automatically, needing no
configuration beyond opencode on PATH.
The --via and -p/--provider options are independent -- via routes to
the local agent mode while -p continues to use the cloud API path.
Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
Acked-by: Morten Brørup <mb@smartsharesystems.com>
---
v5: Use linux.dev mailbox to send email and update mailmap
v4: Fix AI review comments with Claude Opus which provided by Stephen
v3: Rebase main to fix apply error
v2: Address comments from Stephen, including: not arise
FileNotFoundError and more clarify.
---
.mailmap | 2 +-
devtools/ai/review-patch.py | 305 ++++++++++++++++++++++------
doc/guides/contributing/patches.rst | 10 +
3 files changed, 249 insertions(+), 68 deletions(-)
diff --git a/.mailmap b/.mailmap
index fcb3d1bb3f..d86a09b5b3 100644
--- a/.mailmap
+++ b/.mailmap
@@ -270,7 +270,7 @@ Chengfei Han <han.chengfei@zte.com.cn>
Chengfeng Ye <cyeaa@connect.ust.hk>
Chenghu Yao <yao.chenghu@zte.com.cn>
Chenglian Sun <sunchenglian@loongson.cn>
-Chengwen Feng <fengchengwen@huawei.com>
+Chengwen Feng <fengchengwen@huawei.com> <chengwen.feng@linux.dev>
Chenmin Sun <chenmin.sun@intel.com>
Chenming Chang <ccm@ccm.ink>
Chenna Arnoori <chenna.arnoori@broadcom.com>
diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 5f8d9ed772..c8d81a3333 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -3,9 +3,10 @@
# Copyright(c) 2026 Stephen Hemminger
"""
-Review DPDK patches using AI providers.
+Review DPDK patches using AI providers or a local agent tool.
Supported providers: Anthropic Claude, OpenAI ChatGPT, xAI Grok, Google Gemini
+Supported agent: OpenCode (--via opencode)
"""
import argparse
@@ -577,6 +578,160 @@ def build_google_request(
}
+def _call_opencode(
+ model: str,
+ system_prompt: str,
+ patch_content: str,
+ patch_name: str,
+ agents_path: str = "",
+ output_format: str = "text",
+ verbose: bool = False,
+ timeout: int = 300,
+) -> tuple[str, TokenUsage]:
+ """Call local opencode CLI for review.
+
+ Note: opencode runs with its default agent toolset, which includes
+ write/edit/bash against the working tree (--dir points at the DPDK
+ root). A review should ideally be read-only; restricting the toolset
+ requires opencode to gain a --read-only or --agent flag, which it
+ does not currently expose. Until then, be aware that the reviewing
+ agent can in principle modify the tree it is reviewing.
+ """
+ format_instruction = FORMAT_INSTRUCTIONS.get(output_format, "")
+ user_prompt = (
+ f"Review the attached DPDK patch file '{patch_name}'.\n\n"
+ f"Focus on correctness bugs, C coding style, API requirements, "
+ f"and other guideline violations. "
+ f"Commit message format and SPDX/copyright are checked by "
+ f"checkpatches.sh -- do NOT flag those.\n\n"
+ f"{format_instruction}"
+ )
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".patch", delete=False, prefix="review_"
+ ) as f:
+ f.write(patch_content)
+ patch_temp = f.name
+
+ try:
+ full_message = system_prompt + "\n\n" + user_prompt
+
+ # opencode auto-loads AGENTS.md from --dir; -a is only meaningful
+ # when pointing at a non-default file. Resolve to an absolute
+ # path because --dir differs from the caller's cwd.
+ agents_abs = str(Path(agents_path).resolve()) if agents_path else ""
+
+ cmd = [
+ "opencode",
+ "run",
+ "--format",
+ "json",
+ "--dir",
+ str(Path(__file__).resolve().parent.parent.parent),
+ ]
+ if model:
+ cmd.extend(["--model", model])
+ # In verbose mode, let opencode's own logs reach the user
+ # instead of capturing and discarding them.
+ if verbose:
+ cmd.append("--print-logs")
+ cmd.append(full_message)
+ cmd.extend(["--file", patch_temp])
+ if agents_abs:
+ cmd.extend(["--file", agents_abs])
+
+ if verbose:
+ print(f"Running: {' '.join(cmd)}", file=sys.stderr)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=None if verbose else subprocess.PIPE,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError:
+ error("opencode not found. Install from https://opencode.ai")
+ except subprocess.TimeoutExpired:
+ error(f"opencode timed out after {timeout} seconds")
+
+ if result.returncode != 0:
+ stderr = result.stderr or ""
+ error(
+ f"opencode exited with code {result.returncode}: "
+ f"{stderr[:500]}"
+ )
+
+ finally:
+ os.unlink(patch_temp)
+
+ stdout = result.stdout or ""
+ # Buffer text per messageID so we can keep only the final assistant
+ # turn (the actual review) and drop intermediate narration such as
+ # "Let me read AGENTS.md first...".
+ message_text: dict[str, list[str]] = {}
+ message_order: list[str] = []
+ final_message_id = ""
+ usage = TokenUsage()
+ steps = 0
+
+ for line in stdout.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ try:
+ event = json.loads(stripped)
+ except json.JSONDecodeError:
+ continue
+
+ event_type = event.get("type", "")
+ part = event.get("part", {})
+ if event_type == "text":
+ mid = part.get("messageID", "")
+ if mid:
+ if mid not in message_text:
+ message_text[mid] = []
+ message_order.append(mid)
+ message_text[mid].append(part.get("text", ""))
+ elif event_type == "step_finish":
+ steps += 1
+ if part.get("reason") == "stop":
+ final_message_id = part.get("messageID", "") or final_message_id
+ tokens = part.get("tokens", {})
+ if tokens:
+ usage.input_tokens += tokens.get("input", 0)
+ usage.output_tokens += tokens.get("output", 0)
+ cache = tokens.get("cache", {})
+ usage.cache_creation_tokens += cache.get("write", 0)
+ usage.cache_read_tokens += cache.get("read", 0)
+ elif event_type == "error":
+ err = event.get("error", {})
+ err_name = err.get("name", "unknown")
+ err_msg = err.get("data", {}).get("message", "")
+ error(f"opencode: {err_name}: {err_msg}")
+
+ usage.api_calls = steps
+
+ if final_message_id and final_message_id in message_text:
+ review_text = "\n".join(message_text[final_message_id])
+ elif message_order:
+ # No explicit stop marker; fall back to the last message that
+ # produced text.
+ review_text = "\n".join(message_text[message_order[-1]])
+ else:
+ review_text = ""
+
+ if not review_text:
+ snippet = stdout[:300].replace("\n", " ")
+ error(
+ f"No review text received from opencode; "
+ f"stdout begins: {snippet!r}"
+ )
+
+ return review_text, usage
+
+
def call_api(
provider: str,
auth: str,
@@ -764,6 +919,7 @@ def main() -> None:
Examples:
%(prog)s patch.patch # Review with default settings
%(prog)s -p openai my-patch.patch # Use OpenAI ChatGPT
+ %(prog)s --via opencode my-patch.patch # Use local opencode agent
%(prog)s -f markdown patch.patch # Output as Markdown
%(prog)s -f json -o review.json patch.patch # Save JSON to file
%(prog)s -f html -o review.html patch.patch # Save HTML to file
@@ -809,8 +965,14 @@ def main() -> None:
"-p",
"--provider",
choices=PROVIDERS.keys(),
- default="anthropic",
- help="AI provider (default: anthropic)",
+ default=None,
+ help="Cloud AI provider (default: anthropic)",
+ )
+ parser.add_argument(
+ "--via",
+ choices=["opencode"],
+ default=None,
+ help="Use a local agent tool instead of a cloud API (e.g. --via opencode)",
)
parser.add_argument(
"-a",
@@ -956,12 +1118,26 @@ def main() -> None:
if not args.patch_file:
parser.error("patch_file is required")
- # Get provider config
- config = PROVIDERS[args.provider]
- model = args.model or config["default_model"]
-
- # Get authentication string
- auth = get_auth_string(args.auth, args.provider)
+ # --via and -p/--auth are mutually exclusive; -p defaults to anthropic
+ # when neither --via nor -p is given. Detect explicit -p/--auth by
+ # comparing against the original parser default of None.
+ via = args.via
+ if via and args.provider is not None:
+ parser.error("--via and -p/--provider are mutually exclusive")
+ if via and args.auth != "auto":
+ parser.error("--via and --auth are mutually exclusive")
+ provider = args.provider or "anthropic"
+
+ # Get provider config or set up local agent runner
+ if via:
+ model = args.model or ""
+ auth = ""
+ provider_name = "OpenCode"
+ else:
+ config = PROVIDERS[provider]
+ model = args.model or config["default_model"]
+ auth = get_auth_string(args.auth, provider)
+ provider_name = config["name"]
# Validate files
agents_path = Path(args.agents)
@@ -999,17 +1175,45 @@ def main() -> None:
patch_content = patch_path.read_text(encoding="utf-8", errors="replace")
patch_name = patch_path.name
- # Determine max tokens for this provider
- max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
- args.provider, 100000
- )
+ # Dispatch to agent or provider
+ def _run_review(patch_body: str, patch_label: str) -> tuple[str, TokenUsage]:
+ if via:
+ return _call_opencode(
+ model, system_prompt,
+ patch_body, patch_label,
+ str(agents_path),
+ args.output_format, args.verbose, args.timeout,
+ )
+ return call_api(
+ provider, auth, model, args.tokens,
+ system_prompt, agents_content,
+ patch_body, patch_label,
+ args.output_format, args.verbose, args.timeout,
+ )
- # Estimate token count
- estimated_tokens = estimate_tokens(patch_content + agents_content)
+ # Determine max tokens (cloud API only)
+ max_input_tokens = 0
+ estimated_tokens = 0
+ if via:
+ pass
+ else:
+ max_input_tokens = args.max_tokens or PROVIDER_INPUT_LIMITS.get(
+ provider, 100000
+ )
+ estimated_tokens = estimate_tokens(patch_content + agents_content)
+
+ already_reviewed = False
# Accumulate token usage across all API calls
total_usage = TokenUsage()
+ if via and args.large_file != "error":
+ print(
+ "Warning: --large-file is ignored in --via mode; "
+ "opencode handles large files automatically",
+ file=sys.stderr,
+ )
+
# Parse patch range if specified
patch_start, patch_end = None, None
if args.patch_range:
@@ -1067,19 +1271,8 @@ def main() -> None:
patch_label = f"Patch {i}/{total_patches}"
print(f"\nReviewing {patch_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch,
- f"{patch_name} ({patch_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ label = f"{patch_name} ({patch_label})"
+ review_text, call_usage = _run_review(patch, label)
total_usage.add(call_usage)
all_reviews.append((patch_label, review_text))
@@ -1088,13 +1281,12 @@ def main() -> None:
all_reviews, args.output_format, patch_name
)
- # Skip the normal API call
- estimated_tokens = 0 # Bypass size check since we've already processed
+ already_reviewed = True
- # Check if content is too large
+ # Check if content is too large (cloud API only)
is_large = estimated_tokens > max_input_tokens
- if is_large:
+ if is_large and not via:
print(
f"Warning: Estimated {estimated_tokens:,} tokens exceeds limit of "
f"{max_input_tokens:,}",
@@ -1137,19 +1329,8 @@ def main() -> None:
chunk_label = f"Chunk {chunk_num}/{total_chunks}"
print(f"Reviewing {chunk_label}...", file=sys.stderr)
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- chunk,
- f"{patch_name} ({chunk_label})",
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ label = f"{patch_name} ({chunk_label})"
+ review_text, call_usage = _run_review(chunk, label)
total_usage.add(call_usage)
all_reviews.append((chunk_label, review_text))
@@ -1158,13 +1339,16 @@ def main() -> None:
all_reviews, args.output_format, patch_name
)
- # Skip the normal single API call below
- estimated_tokens = 0
+ already_reviewed = True
if args.verbose:
print("=== Request ===", file=sys.stderr)
- print(f"Provider: {args.provider}", file=sys.stderr)
- print(f"Auth method: {'vertex' if auth == 'vertex' else 'direct'}", file=sys.stderr)
+ if via:
+ print(f"Runner: {args.via}", file=sys.stderr)
+ else:
+ print(f"Provider: {provider}", file=sys.stderr)
+ method = "vertex" if auth == "vertex" else "direct"
+ print(f"Auth method: {method}", file=sys.stderr)
print(f"Model: {model}", file=sys.stderr)
print(f"Review date: {review_date}", file=sys.stderr)
if args.release:
@@ -1190,27 +1374,14 @@ def main() -> None:
print("===============", file=sys.stderr)
# Call API (unless already processed via chunks/split)
- if estimated_tokens > 0: # Not already processed
- review_text, call_usage = call_api(
- args.provider,
- auth,
- model,
- args.tokens,
- system_prompt,
- agents_content,
- patch_content,
- patch_name,
- args.output_format,
- args.verbose,
- args.timeout,
- )
+ if not already_reviewed:
+ review_text, call_usage = _run_review(patch_content, patch_name)
total_usage.add(call_usage)
if not review_text:
- error(f"No response received from {args.provider}")
+ error(f"No response received from {provider_name}")
# Format output based on requested format
- provider_name = config["name"]
if args.output_format == "json":
# For JSON, try to parse and add metadata
@@ -1234,7 +1405,7 @@ def main() -> None:
output_data = {
"metadata": {
"patch_file": patch_name,
- "provider": args.provider,
+ "provider": args.via or provider,
"provider_name": provider_name,
"model": model,
"review_date": review_date,
@@ -1289,7 +1460,7 @@ def main() -> None:
print_token_summary(
total_usage,
- args.provider,
+ args.via or provider,
model,
args.show_tokens or args.verbose,
)
diff --git a/doc/guides/contributing/patches.rst b/doc/guides/contributing/patches.rst
index f4996fd195..bae0a8217a 100644
--- a/doc/guides/contributing/patches.rst
+++ b/doc/guides/contributing/patches.rst
@@ -533,6 +533,13 @@ The script supports multiple AI providers
An API key for the chosen provider must be set
in the corresponding environment variable (see ``--list-providers``).
+Alternatively, the ``--via opencode`` option uses the locally installed
+`opencode <https://opencode.ai>`_ CLI as the review runner instead of
+calling a cloud API directly.
+opencode reads ``AGENTS.md`` from the DPDK project directory
+and selects the model from its own configuration,
+so no API key needs to be set in the environment.
+
Basic usage::
# Review a single patch (default provider: Anthropic Claude)
@@ -541,6 +548,9 @@ Basic usage::
# Use a different provider
devtools/ai/review-patch.py -p openai my-patch.patch
+ # Use the local opencode agent instead of a cloud API
+ devtools/ai/review-patch.py --via opencode my-patch.patch
+
# Review for an LTS branch (enables stricter rules)
devtools/ai/review-patch.py -r 24.11 my-patch.patch
--
2.55.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* Re: [PATCH v5] devtools: support local opencode agent for patch review
2026-08-07 3:11 ` [PATCH v5] " Chengwen Feng
@ 2026-08-07 3:32 ` fengchengwen
0 siblings, 0 replies; 8+ messages in thread
From: fengchengwen @ 2026-08-07 3:32 UTC (permalink / raw)
To: Chengwen Feng, thomas, stephen; +Cc: aconole, dev
On 8/7/2026 11:11 AM, Chengwen Feng wrote:
> From: Chengwen Feng <fengchengwen@huawei.com>
>
> Currently review-patch.py only supports cloud AI providers
> (Anthropic, OpenAI, xAI, Google) via REST API, requiring API keys.
>
> Add a --via option that invokes the locally installed opencode CLI as
> the review runner instead of making HTTP calls. opencode reads
> AGENTS.md from the DPDK project directory automatically, needing no
> configuration beyond opencode on PATH.
>
> The --via and -p/--provider options are independent -- via routes to
> the local agent mode while -p continues to use the cloud API path.
>
> Signed-off-by: Chengwen Feng <fengchengwen@huawei.com>
> Acked-by: Morten Brørup <mb@smartsharesystems.com>
> ---
> v5: Use linux.dev mailbox to send email and update mailmap
> v4: Fix AI review comments with Claude Opus which provided by Stephen
> v3: Rebase main to fix apply error
> v2: Address comments from Stephen, including: not arise
> FileNotFoundError and more clarify.
>
> ---
> .mailmap | 2 +-
> devtools/ai/review-patch.py | 305 ++++++++++++++++++++++------
> doc/guides/contributing/patches.rst | 10 +
> 3 files changed, 249 insertions(+), 68 deletions(-)
>
> diff --git a/.mailmap b/.mailmap
> index fcb3d1bb3f..d86a09b5b3 100644
> --- a/.mailmap
> +++ b/.mailmap
> @@ -270,7 +270,7 @@ Chengfei Han <han.chengfei@zte.com.cn>
> Chengfeng Ye <cyeaa@connect.ust.hk>
> Chenghu Yao <yao.chenghu@zte.com.cn>
> Chenglian Sun <sunchenglian@loongson.cn>
> -Chengwen Feng <fengchengwen@huawei.com>
> +Chengwen Feng <fengchengwen@huawei.com> <chengwen.feng@linux.dev>
For this new email address:
Acked-by: Chengwen Feng <fengchengwen@huawei.com>
^ permalink raw reply [flat|nested] 8+ messages in thread
end of thread, other threads:[~2026-08-07 3:33 UTC | newest]
Thread overview: 8+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
[not found] <20260703091902.525837-1-datshan@qq.com>
2026-07-06 6:29 ` [PATCH v2] devtools: support local opencode agent for patch review datshan
2026-07-25 8:43 ` [PATCH v3] " datshan
2026-08-04 8:41 ` fengchengwen
2026-08-04 12:22 ` Morten Brørup
2026-08-04 16:18 ` Stephen Hemminger
2026-08-05 6:12 ` [PATCH v4] " datshan
2026-08-07 3:11 ` [PATCH v5] " Chengwen Feng
2026-08-07 3:32 ` fengchengwen
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox